From ce9de7797ec020f5ee44487323096dd5c6e8a8c6 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Thu, 13 Nov 2025 11:37:04 +0100 Subject: [PATCH 01/30] remove totalAsk and proposalsCount from campaign model --- .../campaign_timeline/campaign_timeline.dart | 11 +-- .../lib/src/discovery/discovery_cubit.dart | 96 +++++++++++++------ .../src/discovery/discovery_cubit_cache.dart | 21 ++++ .../lib/src/discovery/discovery_state.dart | 2 +- .../test/workspace/workspace_bloc_test.dart | 6 -- .../lib/src/campaign/campaign.dart | 10 -- .../lib/src/campaign/campaign_category.dart | 12 +-- .../f14_static_campaign_categories.dart | 8 -- .../f15_static_campaign_categories.dart | 8 -- .../test/campaign/campaign_test.dart | 6 -- .../lib/src/campaign/campaign_service.dart | 56 +---------- .../campaign_timeline_view_model.dart | 9 -- .../current_campaign_info_view_model.dart | 9 -- .../test/campaign/campaign_stage_test.dart | 3 - 14 files changed, 96 insertions(+), 161 deletions(-) create mode 100644 catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit_cache.dart diff --git a/catalyst_voices/apps/voices/lib/widgets/campaign_timeline/campaign_timeline.dart b/catalyst_voices/apps/voices/lib/widgets/campaign_timeline/campaign_timeline.dart index 03ec92b5c745..3d4e50a88f51 100644 --- a/catalyst_voices/apps/voices/lib/widgets/campaign_timeline/campaign_timeline.dart +++ b/catalyst_voices/apps/voices/lib/widgets/campaign_timeline/campaign_timeline.dart @@ -35,16 +35,7 @@ class CampaignTimelineState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ widget.horizontalPadding, - ...widget.timelineItems - .where((e) => !e.offstage) - .toList() - .asMap() - .entries - .map( - (entry) => CampaignTimelineCard( - timelineItem: entry.value, - ), - ), + ...widget.timelineItems.map((e) => CampaignTimelineCard(timelineItem: e)), widget.horizontalPadding, ], ), 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 404853bac087..734036bc1b79 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 @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_blocs/src/discovery/discovery_cubit_cache.dart'; 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'; @@ -9,6 +10,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; const _maxRecentProposalsCount = 7; +const List _offstagePhases = [CampaignPhaseType.reviewRegistration]; + final _logger = Logger('DiscoveryCubit'); /// Manages all data for the discovery screen. @@ -18,9 +21,15 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { final CampaignService _campaignService; final ProposalService _proposalService; + DiscoveryCubitCache _cache = const DiscoveryCubitCache(); + StreamSubscription>? _proposalsV2Sub; + StreamSubscription? _activeCampaignSub; - DiscoveryCubit(this._campaignService, this._proposalService) : super(const DiscoveryState()); + DiscoveryCubit( + this._campaignService, + this._proposalService, + ) : super(const DiscoveryState()); Future addFavorite(DocumentRef ref) async { try { @@ -36,6 +45,9 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { await _proposalsV2Sub?.cancel(); _proposalsV2Sub = null; + await _activeCampaignSub?.cancel(); + _activeCampaignSub = null; + return super.close(); } @@ -46,40 +58,20 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { Future getCurrentCampaign() async { try { - emit(state.copyWith(campaign: const DiscoveryCampaignState())); - - final campaign = (await _campaignService.getActiveCampaign())!; - final timeline = campaign.timeline.phases.map(CampaignTimelineViewModel.fromModel).toList(); - final currentCampaign = CurrentCampaignInfoViewModel.fromModel(campaign); - final categoriesModel = campaign.categories - .map(CampaignCategoryDetailsViewModel.fromModel) - .toList(); - final datesEvents = _buildCampaignDatesEvents(timeline); - - if (isClosed) { - return; - } + emit(state.copyWith(campaign: const DiscoveryCampaignState(isLoading: true))); + + final campaign = await _campaignService.getActiveCampaign(); - emit( - state.copyWith( - campaign: DiscoveryCampaignState( - currentCampaign: currentCampaign, - campaignTimeline: timeline, - categories: categoriesModel, - datesEvents: datesEvents, - isLoading: false, - ), - ), - ); + if (!isClosed) { + _handleActiveCampaignChange(campaign); + _watchActiveCampaign(); + } } catch (e, st) { _logger.severe('Error getting current campaign', e, st); if (!isClosed) { - emit( - state.copyWith( - campaign: DiscoveryCampaignState(error: LocalizedException.create(e)), - ), - ); + final campaignState = DiscoveryCampaignState(error: LocalizedException.create(e)); + emit(state.copyWith(campaign: campaignState)); } } } @@ -144,6 +136,42 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { ); } + void _handleActiveCampaignChange(Campaign? campaign) { + if (_cache.activeCampaign?.selfRef == campaign?.selfRef) { + return; + } + + _cache = _cache.copyWith(activeCampaign: Optional(campaign)); + + final phases = campaign?.timeline.phases ?? []; + final timeline = phases + .where((phase) => !_offstagePhases.contains(phase.type)) + .map(CampaignTimelineViewModel.fromModel) + .toList(); + final datesEvents = _buildCampaignDatesEvents(timeline); + + final currentCampaign = CurrentCampaignInfoViewModel( + title: campaign?.name ?? '', + allFunds: + campaign?.allFunds ?? + MultiCurrencyAmount.single(Money.zero(currency: Currencies.fallback)), + totalAsk: MultiCurrencyAmount.single(Money.zero(currency: Currencies.fallback)), + timeline: timeline, + ); + + final categories = campaign?.categories ?? []; + final categoriesModel = categories.map(CampaignCategoryDetailsViewModel.fromModel).toList(); + + final campaignState = DiscoveryCampaignState( + currentCampaign: currentCampaign, + campaignTimeline: timeline, + categories: categoriesModel, + datesEvents: datesEvents, + ); + + emit(state.copyWith(campaign: campaignState)); + } + void _handleProposalsChange(List proposals) { _logger.finest('Got proposals[${proposals.length}]'); @@ -155,6 +183,14 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { emit(state.copyWith(proposals: updatedProposalsState)); } + void _watchActiveCampaign() { + unawaited(_activeCampaignSub?.cancel()); + + _activeCampaignSub = _campaignService.watchActiveCampaign + .distinct((previous, next) => previous?.selfRef != next?.selfRef) + .listen(_handleActiveCampaignChange); + } + void _watchMostRecentProposals() { unawaited(_proposalsV2Sub?.cancel()); diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit_cache.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit_cache.dart new file mode 100644 index 000000000000..058f73afb2d2 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit_cache.dart @@ -0,0 +1,21 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +final class DiscoveryCubitCache extends Equatable { + final Campaign? activeCampaign; + + const DiscoveryCubitCache({ + this.activeCampaign, + }); + + @override + List get props => [activeCampaign]; + + DiscoveryCubitCache copyWith({ + Optional? activeCampaign, + }) { + return DiscoveryCubitCache( + activeCampaign: activeCampaign.dataOr(this.activeCampaign), + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_state.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_state.dart index f0b024e48617..9cfb5866de4f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_state.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_state.dart @@ -27,7 +27,7 @@ final class DiscoveryCampaignState extends Equatable { final CampaignDatesEventsState datesEvents; const DiscoveryCampaignState({ - this.isLoading = true, + this.isLoading = false, this.error, this.currentCampaign, this.campaignTimeline = const [], diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/test/workspace/workspace_bloc_test.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/test/workspace/workspace_bloc_test.dart index 1f0ca4d6cbaa..8c26add7f4a7 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/test/workspace/workspace_bloc_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/test/workspace/workspace_bloc_test.dart @@ -68,7 +68,6 @@ void main() { name: 'Catalyst Fund14', description: 'Description', allFunds: MultiCurrencyAmount.single(_adaMajorUnits(20000000)), - totalAsk: MultiCurrencyAmount.single(_adaMajorUnits(0)), fundNumber: 14, timeline: const CampaignTimeline(phases: []), publish: CampaignPublish.published, @@ -81,10 +80,8 @@ void main() { categorySubname: 'Test Subname', description: 'Test description', shortDescription: 'Test short description', - proposalsCount: 0, availableFunds: MultiCurrencyAmount.single(_adaMajorUnits(1000)), imageUrl: '', - totalAsk: MultiCurrencyAmount.single(_adaMajorUnits(0)), range: Range( min: _adaMajorUnits(10), max: _adaMajorUnits(100), @@ -123,7 +120,6 @@ void main() { name: 'Catalyst Fund14', description: 'Description', allFunds: MultiCurrencyAmount.single(_adaMajorUnits(20000000)), - totalAsk: MultiCurrencyAmount.single(_adaMajorUnits(0)), // TODO(LynxLynxx): refactor it when _mapProposalToViewModel will be refactored fundNumber: 0, timeline: const CampaignTimeline(phases: []), @@ -137,10 +133,8 @@ void main() { categorySubname: 'Test Subname', description: 'Test description', shortDescription: 'Test short description', - proposalsCount: 0, availableFunds: MultiCurrencyAmount.single(_adaMajorUnits(1000)), imageUrl: '', - totalAsk: MultiCurrencyAmount.single(_adaMajorUnits(0)), range: Range( min: _adaMajorUnits(10), max: _adaMajorUnits(100), diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign.dart index 09e1ba2876ee..3c1da5468e7b 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign.dart @@ -24,7 +24,6 @@ final class Campaign extends Equatable { final String name; final String description; final MultiCurrencyAmount allFunds; - final MultiCurrencyAmount totalAsk; final int fundNumber; final CampaignTimeline timeline; final List categories; @@ -35,7 +34,6 @@ final class Campaign extends Equatable { required this.name, required this.description, required this.allFunds, - required this.totalAsk, required this.fundNumber, required this.timeline, required this.categories, @@ -49,7 +47,6 @@ final class Campaign extends Equatable { description: ''' Project Catalyst turns economic power into innovation power by using the Cardano Treasury to incentivize and fund community-approved ideas.''', allFunds: MultiCurrencyAmount.single(Currencies.ada.amount(20000000)), - totalAsk: MultiCurrencyAmount.single(Money.zero(currency: Currencies.ada)), fundNumber: 14, timeline: f14StaticCampaignTimeline, publish: CampaignPublish.published, @@ -66,10 +63,6 @@ Project Catalyst turns economic power into innovation power by using the Cardano Currencies.ada.amount(20000000), Currencies.usdm.amount(250000), ]), - totalAsk: MultiCurrencyAmount.list([ - Money.zero(currency: Currencies.ada), - Money.zero(currency: Currencies.usdm), - ]), fundNumber: 15, timeline: f15StaticCampaignTimeline, publish: CampaignPublish.published, @@ -112,7 +105,6 @@ Project Catalyst turns economic power into innovation power by using the Cardano name, description, allFunds, - totalAsk, fundNumber, timeline, publish, @@ -165,7 +157,6 @@ Project Catalyst turns economic power into innovation power by using the Cardano String? name, String? description, MultiCurrencyAmount? allFunds, - MultiCurrencyAmount? totalAsk, int? fundNumber, CampaignTimeline? timeline, CampaignPublish? publish, @@ -176,7 +167,6 @@ Project Catalyst turns economic power into innovation power by using the Cardano name: name ?? this.name, description: description ?? this.description, allFunds: allFunds ?? this.allFunds, - totalAsk: totalAsk ?? this.totalAsk, fundNumber: fundNumber ?? this.fundNumber, timeline: timeline ?? this.timeline, publish: publish ?? this.publish, diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category.dart index 3186e890bd73..b09d2dacd247 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category.dart @@ -13,15 +13,15 @@ class CampaignCategory extends Equatable { final String categorySubname; final String description; final String shortDescription; - final int proposalsCount; final MultiCurrencyAmount availableFunds; - final MultiCurrencyAmount totalAsk; final Range range; final Currency currency; final List descriptions; final String imageUrl; final List dos; final List donts; + + // TODO(damian-molinski): remove this final DateTime submissionCloseDate; const CampaignCategory({ @@ -32,10 +32,8 @@ class CampaignCategory extends Equatable { required this.categorySubname, required this.description, required this.shortDescription, - required this.proposalsCount, required this.availableFunds, required this.imageUrl, - required this.totalAsk, required this.range, required this.currency, required this.descriptions, @@ -55,10 +53,8 @@ class CampaignCategory extends Equatable { categorySubname, description, shortDescription, - proposalsCount, availableFunds, imageUrl, - totalAsk, range, descriptions, dos, @@ -74,10 +70,8 @@ class CampaignCategory extends Equatable { String? categorySubname, String? description, String? shortDescription, - int? proposalsCount, MultiCurrencyAmount? availableFunds, String? imageUrl, - MultiCurrencyAmount? totalAsk, Range? range, Currency? currency, List? descriptions, @@ -93,10 +87,8 @@ class CampaignCategory extends Equatable { categorySubname: categorySubname ?? this.categorySubname, description: description ?? this.description, shortDescription: shortDescription ?? this.shortDescription, - proposalsCount: proposalsCount ?? this.proposalsCount, availableFunds: availableFunds ?? this.availableFunds, imageUrl: imageUrl ?? this.imageUrl, - totalAsk: totalAsk ?? this.totalAsk, range: range ?? this.range, currency: currency ?? this.currency, descriptions: descriptions ?? this.descriptions, diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f14_static_campaign_categories.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f14_static_campaign_categories.dart index fe0dd0e86f9a..f4ac9086cfd0 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f14_static_campaign_categories.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f14_static_campaign_categories.dart @@ -17,10 +17,8 @@ final f14StaticCampaignCategories = [ '''Cardano Use Cases: Partners & Products empowers exceptional applications and enterprise collaborations to enhance products and services with capabilities that drive high-volume transactions and accelerates mainstream adoption.''', shortDescription: 'For Tier-1 collaborations and real-world pilots that scale Cardano adoption through high-impact use cases.', - proposalsCount: 0, availableFunds: MultiCurrencyAmount.single(Currencies.ada.amount(8500000)), imageUrl: '', - totalAsk: MultiCurrencyAmount.single(Currencies.ada.amount(0)), range: Range( min: Currencies.ada.amount(250000), max: Currencies.ada.amount(1000000), @@ -101,10 +99,8 @@ The following will **not** be funded: '''Cardano Use Cases: Concepts funds novel, early-stage Cardano-based concepts developing proof of concept prototypes through deploying minimum viable products (MVP) to validate innovative products, services, or business models driving Cardano adoption.''', shortDescription: 'For early-stage ideas to create, test, and validate Cardano-based prototypes to MVP innovations.', - proposalsCount: 0, availableFunds: MultiCurrencyAmount.single(Currencies.ada.amount(4000000)), imageUrl: '', - totalAsk: MultiCurrencyAmount.single(Currencies.ada.amount(0)), range: Range( min: Currencies.ada.amount(15000), max: Currencies.ada.amount(100000), @@ -185,10 +181,8 @@ Funds open source tools and environments to enhance the Cardano developer experi ''', shortDescription: 'For developers to build open-source tools that enhance the Cardano developer experience.', - proposalsCount: 0, availableFunds: MultiCurrencyAmount.single(Currencies.ada.amount(3100000)), imageUrl: '', - totalAsk: MultiCurrencyAmount.single(Currencies.ada.amount(0)), range: Range( min: Currencies.ada.amount(15000), max: Currencies.ada.amount(100000), @@ -270,10 +264,8 @@ Funds non-technical initiatives like marketing, education, and community buildin ''', shortDescription: 'For non-tech projects like marketing, education, or community growth to expand Cardano’s reach.', - proposalsCount: 0, availableFunds: MultiCurrencyAmount.single(Currencies.ada.amount(3000000)), imageUrl: '', - totalAsk: MultiCurrencyAmount.single(Currencies.ada.amount(0)), range: Range( min: Currencies.ada.amount(15000), max: Currencies.ada.amount(60000), diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f15_static_campaign_categories.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f15_static_campaign_categories.dart index 524469d21007..e95b3abc649f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f15_static_campaign_categories.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f15_static_campaign_categories.dart @@ -19,7 +19,6 @@ final f15StaticCampaignCategories = [ shortDescription: 'To fund high-impact R&D pilots and integrations led by or in collaboration with established Tier-1 enterprises, driving mainstream Cardano adoption and creating significant co-marketing opportunities.', availableFunds: MultiCurrencyAmount.single(Currencies.ada.amount(10000000)), - totalAsk: MultiCurrencyAmount.single(Currencies.ada.amount(0)), range: Range( min: Currencies.ada.amount(250000), max: Currencies.ada.amount(750000), @@ -132,7 +131,6 @@ Use this checklist to ensure your proposal meets all foundational and content re 'Use vague team bios; provide links.', ], submissionCloseDate: DateTimeExt.now(), - proposalsCount: 0, imageUrl: '', ), @@ -148,7 +146,6 @@ Use this checklist to ensure your proposal meets all foundational and content re shortDescription: 'To provide entrepreneurial individuals and teams with funding to build and launch novel prototypes that have already undergone foundational research and design, accelerating the delivery of new on-chain utility for Cardano.', availableFunds: MultiCurrencyAmount.single(Currencies.ada.amount(6000000)), - totalAsk: MultiCurrencyAmount.single(Currencies.ada.amount(0)), range: Range( min: Currencies.ada.amount(15000), max: Currencies.ada.amount(200000), @@ -241,7 +238,6 @@ Use this checklist to ensure your proposal meets all foundational and content re 'Budget for giveaways/re-grants.', ], submissionCloseDate: DateTimeExt.now(), - proposalsCount: 0, imageUrl: '', ), // //Cardano Open: Ecosystem @@ -256,7 +252,6 @@ Use this checklist to ensure your proposal meets all foundational and content re shortDescription: 'Funds non-technical initiatives like marketing, education, research, and community building to grow Cardano’s ecosystem and onboard new users globally.', availableFunds: MultiCurrencyAmount.single(Currencies.ada.amount(2500000)), - totalAsk: MultiCurrencyAmount.single(Currencies.ada.amount(0)), range: Range( min: Currencies.ada.amount(15000), max: Currencies.ada.amount(60000), @@ -348,7 +343,6 @@ Use this checklist to ensure your proposal meets all foundational and content re 'Focus on giveaways/incentives.', ], submissionCloseDate: DateTimeExt.now(), - proposalsCount: 0, imageUrl: '', ), //Midnight: Compact DApps @@ -363,7 +357,6 @@ Use this checklist to ensure your proposal meets all foundational and content re shortDescription: 'To accelerate developer adoption of Midnight by funding essential open-source reference DApps. This category is seeking reference DApps, and funding will be sponsored by the Midnight Foundation.', availableFunds: MultiCurrencyAmount.single(Currencies.usdm.amount(250000)), - totalAsk: MultiCurrencyAmount.single(Currencies.usdm.amount(0)), range: Range( min: Currencies.usdm.amount(2500), max: Currencies.usdm.amount(10000), @@ -479,7 +472,6 @@ Use this checklist to ensure your proposal meets all foundational and content re 'Outsource the core development.', ], submissionCloseDate: DateTimeExt.now(), - proposalsCount: 0, imageUrl: '', ), ]; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/test/campaign/campaign_test.dart b/catalyst_voices/packages/internal/catalyst_voices_models/test/campaign/campaign_test.dart index ae5dfd96bb37..eb1d16930162 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/test/campaign/campaign_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/test/campaign/campaign_test.dart @@ -111,7 +111,6 @@ void main() { name: 'Campaign 1', description: 'Description 1', allFunds: _multiCurrency(100), - totalAsk: _multiCurrency(0), fundNumber: 1, categories: const [], timeline: CampaignTimeline( @@ -156,7 +155,6 @@ void main() { name: 'Campaign 1', description: 'Description 1', allFunds: _multiCurrency(100), - totalAsk: _multiCurrency(0), fundNumber: 1, categories: const [], timeline: CampaignTimeline( @@ -224,7 +222,6 @@ void main() { name: 'Campaign 1', description: 'Description 1', allFunds: _multiCurrency(100), - totalAsk: _multiCurrency(0), fundNumber: 1, categories: const [], timeline: CampaignTimeline( @@ -285,7 +282,6 @@ void main() { name: 'Campaign 1', description: 'Description 1', allFunds: _multiCurrency(100), - totalAsk: _multiCurrency(0), fundNumber: 1, categories: const [], timeline: CampaignTimeline( @@ -346,7 +342,6 @@ void main() { name: 'Campaign 1', description: 'Description 1', allFunds: _multiCurrency(100), - totalAsk: _multiCurrency(0), fundNumber: 1, categories: const [], timeline: CampaignTimeline( @@ -421,7 +416,6 @@ void main() { name: 'Campaign 1', description: 'Description 1', allFunds: _multiCurrency(100), - totalAsk: _multiCurrency(0), fundNumber: 1, categories: const [], timeline: CampaignTimeline( diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart index 67815e1a1918..b53dab132441 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart @@ -68,22 +68,17 @@ final class CampaignServiceImpl implements CampaignService { required String id, }) async { final campaign = await _campaignRepository.getCampaign(id: id); - final campaignProposals = await _proposalRepository.getProposals( - type: ProposalsFilterType.finals, - ); final proposalSubmissionTime = campaign .phaseStateTo(CampaignPhaseType.proposalSubmission) .phase .timeline .to; - final totalAsk = _calculateTotalAsk(campaignProposals); - final updatedCategories = await _updateCategories( - campaign.categories, - proposalSubmissionTime, - ); + + final updatedCategories = campaign.categories + .map((e) => e.copyWith(submissionCloseDate: proposalSubmissionTime)) + .toList(); return campaign.copyWith( - totalAsk: totalAsk, categories: updatedCategories, ); } @@ -97,7 +92,7 @@ final class CampaignServiceImpl implements CampaignService { final timelineStage = campaign.timeline.phases.firstWhere( (element) => element.type == type, - orElse: () => throw (StateError('Type $type not found')), + orElse: () => throw StateError('Type $type not found'), ); return timelineStage; } @@ -111,53 +106,12 @@ final class CampaignServiceImpl implements CampaignService { ); } - final categoryProposals = await _proposalRepository.getProposals( - type: ProposalsFilterType.finals, - categoryRef: ref, - ); final proposalSubmissionStage = await getCampaignPhaseTimeline( CampaignPhaseType.proposalSubmission, ); - final totalAsk = _calculateTotalAsk(categoryProposals); return category.copyWith( - totalAsk: totalAsk, - proposalsCount: categoryProposals.length, submissionCloseDate: proposalSubmissionStage.timeline.to, ); } - - MultiCurrencyAmount _calculateTotalAsk(List proposals) { - final totalAmount = MultiCurrencyAmount(); - for (final proposal in proposals) { - final fundsRequested = proposal.document.fundsRequested; - if (fundsRequested != null) { - totalAmount.add(fundsRequested); - } - } - return totalAmount; - } - - Future> _updateCategories( - List categories, - DateTime? proposalSubmissionTime, - ) async { - final updatedCategories = []; - - for (final category in categories) { - final categoryProposals = await _proposalRepository.getProposals( - type: ProposalsFilterType.finals, - categoryRef: category.selfRef, - ); - final totalAsk = _calculateTotalAsk(categoryProposals); - - final updatedCategory = category.copyWith( - totalAsk: totalAsk, - proposalsCount: categoryProposals.length, - submissionCloseDate: proposalSubmissionTime, - ); - updatedCategories.add(updatedCategory); - } - return updatedCategories; - } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_timeline_view_model.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_timeline_view_model.dart index f90b66b7b7b6..f8d75d013b70 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_timeline_view_model.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_timeline_view_model.dart @@ -6,23 +6,16 @@ import 'package:equatable/equatable.dart'; /// /// This view model is used to display the timeline of a campaign. class CampaignTimelineViewModel extends Equatable { - static const List offstagePhases = [ - CampaignPhaseType.reviewRegistration, - ]; - final String title; final String description; final DateRange timeline; final CampaignPhaseType type; - final bool offstage; - const CampaignTimelineViewModel({ required this.title, required this.description, required this.timeline, required this.type, - this.offstage = false, }); factory CampaignTimelineViewModel.fromModel(CampaignPhase model) => CampaignTimelineViewModel( @@ -33,7 +26,6 @@ class CampaignTimelineViewModel extends Equatable { to: model.timeline.to, ), type: model.type, - offstage: offstagePhases.contains(model.type), ); @override @@ -42,6 +34,5 @@ class CampaignTimelineViewModel extends Equatable { description, timeline, type, - offstage, ]; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/current_campaign_info_view_model.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/current_campaign_info_view_model.dart index 113099cbdb6f..96418ec65bcb 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/current_campaign_info_view_model.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/current_campaign_info_view_model.dart @@ -36,15 +36,6 @@ class CurrentCampaignInfoViewModel extends Equatable { ); } - factory CurrentCampaignInfoViewModel.fromModel(Campaign model) { - return CurrentCampaignInfoViewModel( - title: model.name, - allFunds: model.allFunds, - totalAsk: model.totalAsk, - timeline: model.timeline.phases.map(CampaignTimelineViewModel.fromModel).toList(), - ); - } - @override List get props => [ allFunds, diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/test/campaign/campaign_stage_test.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/test/campaign/campaign_stage_test.dart index 7cce7f4495c5..91239748ac9f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/test/campaign/campaign_stage_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/test/campaign/campaign_stage_test.dart @@ -12,9 +12,6 @@ void main() { allFunds: MultiCurrencyAmount.single( Money.zero(currency: Currencies.ada), ), - totalAsk: MultiCurrencyAmount.single( - Money.zero(currency: Currencies.ada), - ), fundNumber: 1, timeline: const CampaignTimeline(phases: []), categories: const [], From 0f35e10116ece373b0eb3dbdbc1a31068be41ac9 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Thu, 13 Nov 2025 11:39:31 +0100 Subject: [PATCH 02/30] proposalsCount -> finalProposalsCount --- .../voices/lib/pages/category/card_information.dart | 2 +- .../lib/pages/category/category_detail_view.dart | 8 ++++---- .../lib/widgets/cards/campaign_category_card.dart | 8 ++++---- .../widgets/cards/category_proposals_details_card.dart | 8 ++++---- .../lib/src/campaign/campaign_category_view_model.dart | 10 +++++----- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/catalyst_voices/apps/voices/lib/pages/category/card_information.dart b/catalyst_voices/apps/voices/lib/pages/category/card_information.dart index 3325efacbad6..0647cbfb059e 100644 --- a/catalyst_voices/apps/voices/lib/pages/category/card_information.dart +++ b/catalyst_voices/apps/voices/lib/pages/category/card_information.dart @@ -30,7 +30,7 @@ class CardInformation extends StatelessWidget { CategoryProposalsDetailsCard( categoryId: category.id, categoryName: category.formattedName, - categoryProposalsCount: category.proposalsCount, + categoryFinalProposalsCount: category.finalProposalsCount, ), const SizedBox(height: 16), Offstage( diff --git a/catalyst_voices/apps/voices/lib/pages/category/category_detail_view.dart b/catalyst_voices/apps/voices/lib/pages/category/category_detail_view.dart index 92192e7c9a31..94c210cd54a4 100644 --- a/catalyst_voices/apps/voices/lib/pages/category/category_detail_view.dart +++ b/catalyst_voices/apps/voices/lib/pages/category/category_detail_view.dart @@ -34,7 +34,7 @@ class CategoryDetailView extends StatelessWidget { categoryDescription: category.description, categoryRef: category.id, image: category.image, - proposalCount: category.proposalsCount, + finalProposalsCount: category.finalProposalsCount, ), const SizedBox(height: 64), FundsDetailCard( @@ -59,14 +59,14 @@ class _CategoryBrief extends StatelessWidget { final String categoryDescription; final SignedDocumentRef categoryRef; final SvgGenImage image; - final int proposalCount; + final int finalProposalsCount; const _CategoryBrief({ required this.categoryName, required this.categoryDescription, required this.categoryRef, required this.image, - required this.proposalCount, + required this.finalProposalsCount, }); @override @@ -98,7 +98,7 @@ class _CategoryBrief extends StatelessWidget { categoryName: categoryName, categoryDescription: categoryDescription, categoryRef: categoryRef, - showViewAllButton: proposalCount > 0, + showViewAllButton: finalProposalsCount > 0, ), ), ], diff --git a/catalyst_voices/apps/voices/lib/widgets/cards/campaign_category_card.dart b/catalyst_voices/apps/voices/lib/widgets/cards/campaign_category_card.dart index 1ac46ebb8a6f..97319f907be0 100644 --- a/catalyst_voices/apps/voices/lib/widgets/cards/campaign_category_card.dart +++ b/catalyst_voices/apps/voices/lib/widgets/cards/campaign_category_card.dart @@ -50,7 +50,7 @@ class CampaignCategoryCard extends StatelessWidget { const SizedBox(height: 16), _CampaignStats( availableFunds: category.availableFundsText, - proposalsCount: category.proposalsCount, + finalProposalsCount: category.finalProposalsCount, ), const SizedBox(height: 16), Flexible( @@ -162,11 +162,11 @@ class _Buttons extends StatelessWidget { class _CampaignStats extends StatelessWidget { final String availableFunds; - final int proposalsCount; + final int finalProposalsCount; const _CampaignStats({ required this.availableFunds, - required this.proposalsCount, + required this.finalProposalsCount, }); @override @@ -202,7 +202,7 @@ class _CampaignStats extends StatelessWidget { _TextStats( key: const Key('ProposalsCount'), text: context.l10n.proposals, - value: proposalsCount.toString(), + value: finalProposalsCount.toString(), ), ], ), diff --git a/catalyst_voices/apps/voices/lib/widgets/cards/category_proposals_details_card.dart b/catalyst_voices/apps/voices/lib/widgets/cards/category_proposals_details_card.dart index c0db570edfce..57c5c942e453 100644 --- a/catalyst_voices/apps/voices/lib/widgets/cards/category_proposals_details_card.dart +++ b/catalyst_voices/apps/voices/lib/widgets/cards/category_proposals_details_card.dart @@ -10,13 +10,13 @@ import 'package:flutter/material.dart'; class CategoryProposalsDetailsCard extends StatelessWidget { final SignedDocumentRef categoryId; final String categoryName; - final int categoryProposalsCount; + final int categoryFinalProposalsCount; const CategoryProposalsDetailsCard({ super.key, required this.categoryId, required this.categoryName, - required this.categoryProposalsCount, + required this.categoryFinalProposalsCount, }); @override @@ -28,7 +28,7 @@ class CategoryProposalsDetailsCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - context.l10n.proposalsSubmittedCount(categoryProposalsCount), + context.l10n.proposalsSubmittedCount(categoryFinalProposalsCount), style: context.textTheme.titleMedium?.copyWith( color: context.colors.textOnPrimaryLevel0, ), @@ -41,7 +41,7 @@ class CategoryProposalsDetailsCard extends StatelessWidget { ), const SizedBox(height: 16), Offstage( - offstage: categoryProposalsCount == 0, + offstage: categoryFinalProposalsCount == 0, child: VoicesOutlinedButton( child: Text(context.l10n.viewProposals), onTap: () { diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category_view_model.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category_view_model.dart index e8e9c22f0ece..cbc8761f9382 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category_view_model.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category_view_model.dart @@ -15,7 +15,7 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { final String subname; final String description; final String shortDescription; - final int proposalsCount; + final int finalProposalsCount; final MultiCurrencyAmount availableFunds; final MultiCurrencyAmount totalAsk; final Range range; @@ -31,7 +31,7 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { required this.subname, required this.description, required this.shortDescription, - required this.proposalsCount, + required this.finalProposalsCount, required this.availableFunds, required this.image, required this.totalAsk, @@ -49,7 +49,7 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { subname: model.categorySubname, description: model.description, shortDescription: model.shortDescription, - proposalsCount: model.proposalsCount, + finalProposalsCount: model.finalProposalsCount, availableFunds: model.availableFunds, image: CategoryImageUrl.image(model.selfRef.id), totalAsk: model.totalAsk, @@ -73,7 +73,7 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { description: '''Supports development of open source technology, centered around improving the Cardano developer experience and creating developer-friendly tooling that streamlines an integrated development environment.''', shortDescription: '', - proposalsCount: 263, + finalProposalsCount: 263, availableFunds: MultiCurrencyAmount.single( Money.fromMajorUnits( currency: Currencies.ada, @@ -118,7 +118,7 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { ...super.props, subname, description, - proposalsCount, + finalProposalsCount, availableFunds, totalAsk, range, From 08d8c9228e57e8fe96845b5d2d6c7d4e747d3ce9 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Thu, 13 Nov 2025 11:50:05 +0100 Subject: [PATCH 03/30] CampaignCategoryViewModel.id -> CampaignCategoryViewModel.ref --- .../lib/pages/category/card_information.dart | 4 ++-- .../category_compact_detail_view.dart | 2 +- .../pages/category/category_detail_view.dart | 2 +- .../lib/pages/category/category_page.dart | 2 +- .../category/change_category_button.dart | 7 +++--- .../widgets/campaign_categories.dart | 2 +- .../widgets/cards/campaign_category_card.dart | 2 +- .../category_proposals_details_card.dart | 6 ++--- .../widgets/cards/create_proposal_card.dart | 6 ++--- ...reate_new_proposal_category_selection.dart | 6 ++--- .../src/category/category_detail_cubit.dart | 2 +- .../new_proposal/new_proposal_state.dart | 3 ++- .../campaign_category_view_model.dart | 22 +++++++++++-------- 13 files changed, 36 insertions(+), 30 deletions(-) diff --git a/catalyst_voices/apps/voices/lib/pages/category/card_information.dart b/catalyst_voices/apps/voices/lib/pages/category/card_information.dart index 0647cbfb059e..25a31d1f3797 100644 --- a/catalyst_voices/apps/voices/lib/pages/category/card_information.dart +++ b/catalyst_voices/apps/voices/lib/pages/category/card_information.dart @@ -28,7 +28,7 @@ class CardInformation extends StatelessWidget { padding: padding, children: [ CategoryProposalsDetailsCard( - categoryId: category.id, + categoryRef: category.ref, categoryName: category.formattedName, categoryFinalProposalsCount: category.finalProposalsCount, ), @@ -36,7 +36,7 @@ class CardInformation extends StatelessWidget { Offstage( offstage: !isActiveProposer, child: CreateProposalCard( - categoryId: category.id, + categoryRef: category.ref, categoryName: category.formattedName, categoryDos: category.dos, categoryDonts: category.donts, diff --git a/catalyst_voices/apps/voices/lib/pages/category/category_compact_detail_view.dart b/catalyst_voices/apps/voices/lib/pages/category/category_compact_detail_view.dart index b10f8a50b7c4..ebe730b44756 100644 --- a/catalyst_voices/apps/voices/lib/pages/category/category_compact_detail_view.dart +++ b/catalyst_voices/apps/voices/lib/pages/category/category_compact_detail_view.dart @@ -32,7 +32,7 @@ class CategoryCompactDetailView extends StatelessWidget { _CategoryBrief( categoryName: category.formattedName, categoryDescription: category.description, - categoryRef: category.id, + categoryRef: category.ref, ), FundsDetailCard( allFunds: category.availableFunds, diff --git a/catalyst_voices/apps/voices/lib/pages/category/category_detail_view.dart b/catalyst_voices/apps/voices/lib/pages/category/category_detail_view.dart index 94c210cd54a4..16ea3fdd04a5 100644 --- a/catalyst_voices/apps/voices/lib/pages/category/category_detail_view.dart +++ b/catalyst_voices/apps/voices/lib/pages/category/category_detail_view.dart @@ -32,7 +32,7 @@ class CategoryDetailView extends StatelessWidget { _CategoryBrief( categoryName: category.formattedName, categoryDescription: category.description, - categoryRef: category.id, + categoryRef: category.ref, image: category.image, finalProposalsCount: category.finalProposalsCount, ), diff --git a/catalyst_voices/apps/voices/lib/pages/category/category_page.dart b/catalyst_voices/apps/voices/lib/pages/category/category_page.dart index f368ec5a8514..173ed3416368 100644 --- a/catalyst_voices/apps/voices/lib/pages/category/category_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/category/category_page.dart @@ -225,7 +225,7 @@ class _CategoryPageState extends State { void _listenForProposalRef(CategoryDetailCubit cubit) { // listen for updates _categoryRefSub = cubit.stream - .map((event) => event.category?.id) + .map((event) => event.category?.ref) .distinct() .listen(_onCategoryRefChanged); } diff --git a/catalyst_voices/apps/voices/lib/pages/category/change_category_button.dart b/catalyst_voices/apps/voices/lib/pages/category/change_category_button.dart index c46ce9c5c1f4..b4fe7fc0aad3 100644 --- a/catalyst_voices/apps/voices/lib/pages/category/change_category_button.dart +++ b/catalyst_voices/apps/voices/lib/pages/category/change_category_button.dart @@ -20,13 +20,14 @@ class ChangeCategoryButton extends StatelessWidget { List> >( selector: (state) { - final selectedCategory = state.category?.id ?? ''; + final selectedCategory = state.category?.ref; + return state.categories .map( (e) => DropdownMenuViewModel( - value: ProposalsRefCategoryFilter(ref: e.id), + value: ProposalsRefCategoryFilter(ref: e.ref), name: e.formattedName, - isSelected: e.id == selectedCategory, + isSelected: e.ref == selectedCategory, ), ) .toList(); diff --git a/catalyst_voices/apps/voices/lib/pages/discovery/sections/campaign_details/widgets/campaign_categories.dart b/catalyst_voices/apps/voices/lib/pages/discovery/sections/campaign_details/widgets/campaign_categories.dart index efefedfa1f88..d61735d8e6f2 100644 --- a/catalyst_voices/apps/voices/lib/pages/discovery/sections/campaign_details/widgets/campaign_categories.dart +++ b/catalyst_voices/apps/voices/lib/pages/discovery/sections/campaign_details/widgets/campaign_categories.dart @@ -32,7 +32,7 @@ class CampaignCategories extends StatelessWidget { .map( (e) => Skeletonizer( enabled: isLoading, - child: CampaignCategoryCard(key: ValueKey(e.id), category: e), + child: CampaignCategoryCard(key: ValueKey(e.ref), category: e), ), ) .toList(), diff --git a/catalyst_voices/apps/voices/lib/widgets/cards/campaign_category_card.dart b/catalyst_voices/apps/voices/lib/widgets/cards/campaign_category_card.dart index 97319f907be0..e8f0b25c6583 100644 --- a/catalyst_voices/apps/voices/lib/widgets/cards/campaign_category_card.dart +++ b/catalyst_voices/apps/voices/lib/widgets/cards/campaign_category_card.dart @@ -61,7 +61,7 @@ class CampaignCategoryCard extends StatelessWidget { ), ), _Buttons( - categoryRef: category.id, + categoryRef: category.ref, ), ], ), diff --git a/catalyst_voices/apps/voices/lib/widgets/cards/category_proposals_details_card.dart b/catalyst_voices/apps/voices/lib/widgets/cards/category_proposals_details_card.dart index 57c5c942e453..c9d24b036a09 100644 --- a/catalyst_voices/apps/voices/lib/widgets/cards/category_proposals_details_card.dart +++ b/catalyst_voices/apps/voices/lib/widgets/cards/category_proposals_details_card.dart @@ -8,13 +8,13 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:flutter/material.dart'; class CategoryProposalsDetailsCard extends StatelessWidget { - final SignedDocumentRef categoryId; + final SignedDocumentRef categoryRef; final String categoryName; final int categoryFinalProposalsCount; const CategoryProposalsDetailsCard({ super.key, - required this.categoryId, + required this.categoryRef, required this.categoryName, required this.categoryFinalProposalsCount, }); @@ -45,7 +45,7 @@ class CategoryProposalsDetailsCard extends StatelessWidget { child: VoicesOutlinedButton( child: Text(context.l10n.viewProposals), onTap: () { - ProposalsRoute.fromRef(categoryRef: categoryId).go(context); + ProposalsRoute.fromRef(categoryRef: categoryRef).go(context); }, ), ), diff --git a/catalyst_voices/apps/voices/lib/widgets/cards/create_proposal_card.dart b/catalyst_voices/apps/voices/lib/widgets/cards/create_proposal_card.dart index b56a72382382..93943ee23440 100644 --- a/catalyst_voices/apps/voices/lib/widgets/cards/create_proposal_card.dart +++ b/catalyst_voices/apps/voices/lib/widgets/cards/create_proposal_card.dart @@ -9,7 +9,7 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:flutter/material.dart'; class CreateProposalCard extends StatelessWidget { - final SignedDocumentRef categoryId; + final SignedDocumentRef categoryRef; final String categoryName; final List categoryDos; final List categoryDonts; @@ -17,7 +17,7 @@ class CreateProposalCard extends StatelessWidget { const CreateProposalCard({ super.key, - required this.categoryId, + required this.categoryRef, required this.categoryName, required this.categoryDos, required this.categoryDonts, @@ -51,7 +51,7 @@ class CreateProposalCard extends StatelessWidget { const SizedBox(height: 24), _SubmissionCloseAt(submissionCloseDate), const SizedBox(height: 24), - CreateProposalButton(categoryRef: categoryId), + CreateProposalButton(categoryRef: categoryRef), ], ), ); diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_category_selection.dart b/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_category_selection.dart index 60ba400faf92..1e546f5168a5 100644 --- a/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_category_selection.dart +++ b/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_category_selection.dart @@ -88,7 +88,7 @@ class _CreateNewProposalCategorySelectionState extends State element.id == widget.selectedCategory); + return widget.categories.firstWhereOrNull((element) => element.ref == widget.selectedCategory); } @override @@ -102,8 +102,8 @@ class _CreateNewProposalCategorySelectionState extends State _CategoryCard( name: widget.categories[index].formattedName, description: widget.categories[index].shortDescription, - ref: widget.categories[index].id, - isSelected: widget.categories[index].id == widget.selectedCategory, + ref: widget.categories[index].ref, + isSelected: widget.categories[index].ref == widget.selectedCategory, onCategorySelected: widget.onCategorySelected, ), separatorBuilder: (context, index) => const SizedBox(height: 16), diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit.dart index 2f3bccba981d..3cdc5b9859a5 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit.dart @@ -44,7 +44,7 @@ class CategoryDetailCubit extends Cubit { } Future getCategoryDetail(SignedDocumentRef categoryId) async { - if (categoryId.id == state.category?.id.id) { + if (categoryId.id == state.category?.ref.id) { return emit(state.copyWith(isLoading: false)); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_state.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_state.dart index d227d70d0de8..89c62f4812f2 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_state.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_state.dart @@ -48,8 +48,9 @@ class NewProposalState extends Equatable { categoryRef, categories, ]; + String? get selectedCategoryName => - categories.firstWhereOrNull((e) => e.id == categoryRef)?.formattedName; + categories.firstWhereOrNull((e) => e.ref == categoryRef)?.formattedName; bool get _isAgreementValid => isAgreeToCategoryCriteria && isAgreeToNoFurtherCategoryChange; diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category_view_model.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category_view_model.dart index cbc8761f9382..19bbcebf8fe7 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category_view_model.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category_view_model.dart @@ -26,7 +26,7 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { final DateTime submissionCloseDate; const CampaignCategoryDetailsViewModel({ - required super.id, + required super.ref, required super.name, required this.subname, required this.description, @@ -42,17 +42,21 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { required this.submissionCloseDate, }); - factory CampaignCategoryDetailsViewModel.fromModel(CampaignCategory model) { + factory CampaignCategoryDetailsViewModel.fromModel( + CampaignCategory model, { + int finalProposalsCount = 0, + MultiCurrencyAmount? totalAsk, + }) { return CampaignCategoryDetailsViewModel( - id: model.selfRef, + ref: model.selfRef, name: model.categoryName, subname: model.categorySubname, description: model.description, shortDescription: model.shortDescription, - finalProposalsCount: model.finalProposalsCount, + finalProposalsCount: finalProposalsCount, availableFunds: model.availableFunds, image: CategoryImageUrl.image(model.selfRef.id), - totalAsk: model.totalAsk, + totalAsk: totalAsk ?? MultiCurrencyAmount.single(Money.zero(currency: Currencies.fallback)), range: model.range, descriptions: model.descriptions.map(CategoryDescriptionViewModel.fromModel).toList(), dos: model.dos, @@ -67,7 +71,7 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { /// such as when wrapping widgets with Skeletonizer during data loading. factory CampaignCategoryDetailsViewModel.placeholder({String? id}) { return CampaignCategoryDetailsViewModel( - id: SignedDocumentRef(id: id ?? const Uuid().v7()), + ref: SignedDocumentRef(id: id ?? const Uuid().v7()), name: 'Cardano Open:', subname: 'Developers', description: @@ -131,16 +135,16 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { } final class CampaignCategoryViewModel extends Equatable { - final SignedDocumentRef id; + final SignedDocumentRef ref; final String name; const CampaignCategoryViewModel({ - required this.id, + required this.ref, required this.name, }); @override - List get props => [id, name]; + List get props => [ref, name]; } final class CategoryImageUrl { From d38211917c09238de0511a144ac10e04b464b48d Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Thu, 13 Nov 2025 12:07:53 +0100 Subject: [PATCH 04/30] little DiscoveryCubit state build cleanup --- .../lib/src/discovery/discovery_cubit.dart | 94 +++++++++---------- .../lib/src/campaign/campaign_phase.dart | 10 +- .../campaign_category_view_model.dart | 6 +- 3 files changed, 55 insertions(+), 55 deletions(-) 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 734036bc1b79..0b9d7929ccb7 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 @@ -91,44 +91,22 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { } } - CampaignDatesEventsState _buildCampaignDatesEvents( - List campaignTimeline, - ) { - final reviewItems = - [ - campaignTimeline.firstWhereOrNull( - (e) => e.type == CampaignPhaseType.reviewRegistration, - ), - campaignTimeline.firstWhereOrNull( - (e) => e.type == CampaignPhaseType.communityReview, - ), - ] - .whereType() - .map( - (e) => CampaignTimelineEventWithTitle( - dateRange: e.timeline, - type: e.type, - ), - ) - .toList(); - - final votingItems = - [ - campaignTimeline.firstWhereOrNull( - (e) => e.type == CampaignPhaseType.votingRegistration, - ), - campaignTimeline.firstWhereOrNull( - (e) => e.type == CampaignPhaseType.communityVoting, - ), - ] - .whereType() - .map( - (e) => CampaignTimelineEventWithTitle( - dateRange: e.timeline, - type: e.type, - ), - ) - .toList(); + CampaignDatesEventsState _buildCampaignDatesEventsState(List data) { + final reviewReg = data.firstWhereOrNull((e) => e.type.isReviewRegistration); + final communityRev = data.firstWhereOrNull((e) => e.type.isCommunityReview); + final reviewPhases = [?reviewReg, ?communityRev]; + + final reviewItems = reviewPhases + .map((e) => CampaignTimelineEventWithTitle(dateRange: e.timeline, type: e.type)) + .toList(); + + final votingReg = data.firstWhereOrNull((e) => e.type.isVotingRegistration); + final communityVoting = data.firstWhereOrNull((e) => e.type.isCommunityVoting); + final votingPhases = [?votingReg, ?communityVoting]; + + final votingItems = votingPhases + .map((e) => CampaignTimelineEventWithTitle(dateRange: e.timeline, type: e.type)) + .toList(); return CampaignDatesEventsState( reviewTimelineItems: reviewItems, @@ -143,12 +121,29 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { _cache = _cache.copyWith(activeCampaign: Optional(campaign)); + _updateCampaignState(); + } + + void _handleProposalsChange(List proposals) { + _logger.finest('Got proposals[${proposals.length}]'); + + final updatedProposalsState = state.proposals.copyWith( + proposals: proposals, + showSection: proposals.length == _maxRecentProposalsCount, + ); + + emit(state.copyWith(proposals: updatedProposalsState)); + } + + void _updateCampaignState() { + final campaign = _cache.activeCampaign; + final phases = campaign?.timeline.phases ?? []; final timeline = phases .where((phase) => !_offstagePhases.contains(phase.type)) .map(CampaignTimelineViewModel.fromModel) .toList(); - final datesEvents = _buildCampaignDatesEvents(timeline); + final datesEvents = _buildCampaignDatesEventsState(timeline); final currentCampaign = CurrentCampaignInfoViewModel( title: campaign?.name ?? '', @@ -160,7 +155,15 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { ); final categories = campaign?.categories ?? []; - final categoriesModel = categories.map(CampaignCategoryDetailsViewModel.fromModel).toList(); + final categoriesModel = categories.map( + (e) { + return CampaignCategoryDetailsViewModel.fromModel( + e, + finalProposalsCount: 0, + totalAsk: MultiCurrencyAmount.single(Money.zero(currency: Currencies.fallback)), + ); + }, + ).toList(); final campaignState = DiscoveryCampaignState( currentCampaign: currentCampaign, @@ -172,17 +175,6 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { emit(state.copyWith(campaign: campaignState)); } - void _handleProposalsChange(List proposals) { - _logger.finest('Got proposals[${proposals.length}]'); - - final updatedProposalsState = state.proposals.copyWith( - proposals: proposals, - showSection: proposals.length == _maxRecentProposalsCount, - ); - - emit(state.copyWith(proposals: updatedProposalsState)); - } - void _watchActiveCampaign() { unawaited(_activeCampaignSub?.cancel()); diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_phase.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_phase.dart index ba173c7de557..c30ba1306150 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_phase.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_phase.dart @@ -44,5 +44,13 @@ enum CampaignPhaseType { votingRegistration, reviewRegistration, votingResults, - projectOnboarding, + projectOnboarding; + + bool get isCommunityReview => this == CampaignPhaseType.communityReview; + + bool get isCommunityVoting => this == CampaignPhaseType.communityVoting; + + bool get isReviewRegistration => this == CampaignPhaseType.reviewRegistration; + + bool get isVotingRegistration => this == CampaignPhaseType.votingRegistration; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category_view_model.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category_view_model.dart index 19bbcebf8fe7..8866ed41098d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category_view_model.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category_view_model.dart @@ -44,8 +44,8 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { factory CampaignCategoryDetailsViewModel.fromModel( CampaignCategory model, { - int finalProposalsCount = 0, - MultiCurrencyAmount? totalAsk, + required int finalProposalsCount, + required MultiCurrencyAmount totalAsk, }) { return CampaignCategoryDetailsViewModel( ref: model.selfRef, @@ -56,7 +56,7 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { finalProposalsCount: finalProposalsCount, availableFunds: model.availableFunds, image: CategoryImageUrl.image(model.selfRef.id), - totalAsk: totalAsk ?? MultiCurrencyAmount.single(Money.zero(currency: Currencies.fallback)), + totalAsk: totalAsk, range: model.range, descriptions: model.descriptions.map(CategoryDescriptionViewModel.fromModel).toList(), dos: model.dos, From 83cf26fd5c1574ca5c2ce6d6222498f532be1b44 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Thu, 13 Nov 2025 15:13:46 +0100 Subject: [PATCH 05/30] watch campaign total ask in discovery --- .../lib/src/discovery/discovery_cubit.dart | 44 +++++++++++++++---- .../src/discovery/discovery_cubit_cache.dart | 9 +++- .../campaign/campaign_category_total_ask.dart | 25 +++++++++++ .../lib/src/campaign/campaign_filters.dart | 5 +++ .../lib/src/campaign/campaign_total_ask.dart | 19 ++++++++ .../lib/src/catalyst_voices_models.dart | 2 + .../lib/src/money/multi_currency_amount.dart | 22 +++++++--- .../lib/src/campaign/campaign_service.dart | 8 ++++ 8 files changed, 117 insertions(+), 17 deletions(-) create mode 100644 catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category_total_ask.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_total_ask.dart 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 0b9d7929ccb7..b4eb38921d95 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 @@ -25,6 +25,7 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { StreamSubscription>? _proposalsV2Sub; StreamSubscription? _activeCampaignSub; + StreamSubscription? _activeCampaignTotalAskSub; DiscoveryCubit( this._campaignService, @@ -48,6 +49,9 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { await _activeCampaignSub?.cancel(); _activeCampaignSub = null; + await _activeCampaignTotalAskSub?.cancel(); + _activeCampaignTotalAskSub = null; + return super.close(); } @@ -119,9 +123,21 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { return; } - _cache = _cache.copyWith(activeCampaign: Optional(campaign)); + _cache = _cache.copyWith( + activeCampaign: Optional(campaign), + campaignTotalAsk: const Optional.empty(), + ); _updateCampaignState(); + + unawaited(_activeCampaignTotalAskSub?.cancel()); + _activeCampaignTotalAskSub = null; + + if (campaign != null) _watchCampaignTotalAsk(campaign); + } + + void _handleCampaignTotalAskChange(CampaignTotalAsk data) { + // } void _handleProposalsChange(List proposals) { @@ -137,6 +153,7 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { void _updateCampaignState() { final campaign = _cache.activeCampaign; + final campaignTotalAsk = _cache.campaignTotalAsk ?? const CampaignTotalAsk(categoriesAsks: {}); final phases = campaign?.timeline.phases ?? []; final timeline = phases @@ -147,20 +164,22 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { final currentCampaign = CurrentCampaignInfoViewModel( title: campaign?.name ?? '', - allFunds: - campaign?.allFunds ?? - MultiCurrencyAmount.single(Money.zero(currency: Currencies.fallback)), - totalAsk: MultiCurrencyAmount.single(Money.zero(currency: Currencies.fallback)), + allFunds: campaign?.allFunds ?? MultiCurrencyAmount.zero(), + totalAsk: campaignTotalAsk.totalAsk, timeline: timeline, ); final categories = campaign?.categories ?? []; final categoriesModel = categories.map( - (e) { + (category) { + final categoryTotalAsk = + campaignTotalAsk.categoriesAsks[category.selfRef] ?? + CampaignCategoryTotalAsk.zero(category.selfRef); + return CampaignCategoryDetailsViewModel.fromModel( - e, - finalProposalsCount: 0, - totalAsk: MultiCurrencyAmount.single(Money.zero(currency: Currencies.fallback)), + category, + finalProposalsCount: categoryTotalAsk.finalProposalsCount, + totalAsk: categoryTotalAsk.totalAsk, ); }, ).toList(); @@ -183,6 +202,13 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { .listen(_handleActiveCampaignChange); } + void _watchCampaignTotalAsk(Campaign campaign) { + _activeCampaignTotalAskSub = _campaignService + .watchCampaignTotalAsk(filters: CampaignFilters.from(campaign)) + .distinct() + .listen(_handleCampaignTotalAskChange); + } + void _watchMostRecentProposals() { unawaited(_proposalsV2Sub?.cancel()); diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit_cache.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit_cache.dart index 058f73afb2d2..977a3d726cb7 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit_cache.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit_cache.dart @@ -3,19 +3,26 @@ import 'package:equatable/equatable.dart'; final class DiscoveryCubitCache extends Equatable { final Campaign? activeCampaign; + final CampaignTotalAsk? campaignTotalAsk; const DiscoveryCubitCache({ this.activeCampaign, + this.campaignTotalAsk, }); @override - List get props => [activeCampaign]; + List get props => [ + activeCampaign, + campaignTotalAsk, + ]; DiscoveryCubitCache copyWith({ Optional? activeCampaign, + Optional? campaignTotalAsk, }) { return DiscoveryCubitCache( activeCampaign: activeCampaign.dataOr(this.activeCampaign), + campaignTotalAsk: campaignTotalAsk.dataOr(this.campaignTotalAsk), ); } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category_total_ask.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category_total_ask.dart new file mode 100644 index 000000000000..0c342c18195f --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category_total_ask.dart @@ -0,0 +1,25 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +final class CampaignCategoryTotalAsk extends Equatable { + final DocumentRef ref; + final int finalProposalsCount; + final List money; + + const CampaignCategoryTotalAsk({ + required this.ref, + required this.finalProposalsCount, + required this.money, + }); + + const CampaignCategoryTotalAsk.zero(this.ref) : finalProposalsCount = 0, money = const []; + + @override + List get props => [ + ref, + finalProposalsCount, + money, + ]; + + MultiCurrencyAmount get totalAsk => MultiCurrencyAmount.list(money); +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_filters.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_filters.dart index 2031c1a44c53..705285e48f16 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_filters.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_filters.dart @@ -13,6 +13,11 @@ final class CampaignFilters extends Equatable { return CampaignFilters(categoriesIds: categoriesIds); } + factory CampaignFilters.from(Campaign campaign) { + final categoriesIds = campaign.categories.map((e) => e.selfRef.id).toList(); + return CampaignFilters(categoriesIds: categoriesIds); + } + @override List get props => [categoriesIds]; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_total_ask.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_total_ask.dart new file mode 100644 index 000000000000..e5f8a7357733 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_total_ask.dart @@ -0,0 +1,19 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; + +final class CampaignTotalAsk extends Equatable { + final Map categoriesAsks; + + const CampaignTotalAsk({ + required this.categoriesAsks, + }); + + @override + List get props => [categoriesAsks]; + + MultiCurrencyAmount get totalAsk { + final categoriesMoney = categoriesAsks.values.map((e) => e.money).flattened.toList(); + return MultiCurrencyAmount.list(categoriesMoney); + } +} 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 0ecf9969bd86..4e4c863a96b6 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 @@ -11,9 +11,11 @@ export 'auth/seed_phrase.dart'; export 'blockchain/blockchain_slot_config.dart'; export 'campaign/campaign.dart'; export 'campaign/campaign_category.dart'; +export 'campaign/campaign_category_total_ask.dart'; export 'campaign/campaign_filters.dart'; export 'campaign/campaign_phase.dart'; export 'campaign/campaign_timeline.dart'; +export 'campaign/campaign_total_ask.dart'; export 'common/hi_lo/hi_lo.dart'; export 'common/hi_lo/uuid_hi_lo.dart'; export 'common/markdown_data.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/multi_currency_amount.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/multi_currency_amount.dart index 5315baa34e62..ad7bedffc71e 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/multi_currency_amount.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/multi_currency_amount.dart @@ -1,13 +1,12 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_models/src/money/currency_code.dart'; -import 'package:catalyst_voices_models/src/money/money.dart'; -import 'package:equatable/equatable.dart'; /// Represents a collection of monetary amounts in multiple currencies. /// /// Internally stores a map of [CurrencyCode] to [Money] and provides /// methods to add, subtract, and retrieve amounts. Automatically removes /// zero amounts from the collection. -final class MultiCurrencyAmount extends Equatable { +final class MultiCurrencyAmount { /// Internal map of currency ISO code to [Money] amounts. final Map _map; @@ -41,11 +40,17 @@ final class MultiCurrencyAmount extends Equatable { return group; } - /// Returns all [Money] values in this collection as a list. - List get list => _map.values.toList(); + /// Creates a [MultiCurrencyAmount] with a single zero [Money] value. + /// + /// The currency defaults to [Currencies.fallback] if not specified. + factory MultiCurrencyAmount.zero({ + Currency currency = Currencies.fallback, + }) { + return MultiCurrencyAmount.single(Money.zero(currency: currency)); + } - @override - List get props => [_map]; + /// Returns all [Money] values in this collection as a list. + List get list => List.unmodifiable(_map.values); /// Returns the [Money] value for the given [isoCode], or null if not present. Money? operator [](CurrencyCode isoCode) { @@ -81,6 +86,9 @@ final class MultiCurrencyAmount extends Equatable { _updateMap(updated); } + @override + String toString() => 'MultiCurrencyAmount(${_map.values.join(', ')})'; + /// Updates the internal map with [money]. /// /// If the amount is zero, the entry is removed. Otherwise, it is added/updated. diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart index b53dab132441..31272e18c602 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart @@ -36,6 +36,8 @@ abstract interface class CampaignService { Future getCampaignPhaseTimeline(CampaignPhaseType stage); Future getCategory(SignedDocumentRef ref); + + Stream watchCampaignTotalAsk({required CampaignFilters filters}); } final class CampaignServiceImpl implements CampaignService { @@ -114,4 +116,10 @@ final class CampaignServiceImpl implements CampaignService { submissionCloseDate: proposalSubmissionStage.timeline.to, ); } + + @override + Stream watchCampaignTotalAsk({required CampaignFilters filters}) { + // TODO(damian-molinski): implement it. + return Stream.value(const CampaignTotalAsk(categoriesAsks: {})); + } } From d926bd60905caa65b51184258a991bdc84b2affe Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Thu, 13 Nov 2025 15:38:30 +0100 Subject: [PATCH 06/30] smaller voting category model --- .../appbar/spaces_appbar/voting_appbar.dart | 2 +- .../grid/voting_proposals_sub_header.dart | 2 +- .../header/voting_category_header.dart | 5 ++- .../lib/src/voting/voting_cubit.dart | 13 ++++-- .../lib/src/voting/voting_state.dart | 45 ++++++++++++++----- 5 files changed, 47 insertions(+), 20 deletions(-) diff --git a/catalyst_voices/apps/voices/lib/pages/spaces/appbar/spaces_appbar/voting_appbar.dart b/catalyst_voices/apps/voices/lib/pages/spaces/appbar/spaces_appbar/voting_appbar.dart index 9dc43f2c0b11..0ff00141e464 100644 --- a/catalyst_voices/apps/voices/lib/pages/spaces/appbar/spaces_appbar/voting_appbar.dart +++ b/catalyst_voices/apps/voices/lib/pages/spaces/appbar/spaces_appbar/voting_appbar.dart @@ -42,7 +42,7 @@ class _CategoryVotingAppbar extends StatelessWidget { @override Widget build(BuildContext context) { return BlocSelector( - selector: (state) => state.selectedCategory != null, + selector: (state) => state.hasSelectedCategory, builder: (context, hasCategory) { return _VotingAppbar( showLeading: isAppUnlock && hasCategory, diff --git a/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_sub_header.dart b/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_sub_header.dart index f67cb62d5c43..adb90e254906 100644 --- a/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_sub_header.dart +++ b/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_sub_header.dart @@ -9,7 +9,7 @@ class VotingProposalsSubHeader extends StatelessWidget { @override Widget build(BuildContext context) { return BlocSelector( - selector: (state) => state.selectedCategory != null, + selector: (state) => state.hasSelectedCategory, builder: (context, hasCategory) { return Text( hasCategory ? context.l10n.categoryProposals : context.l10n.proposals, diff --git a/catalyst_voices/apps/voices/lib/pages/voting/widgets/header/voting_category_header.dart b/catalyst_voices/apps/voices/lib/pages/voting/widgets/header/voting_category_header.dart index 1f3e6465f25c..ced2e15aa476 100644 --- a/catalyst_voices/apps/voices/lib/pages/voting/widgets/header/voting_category_header.dart +++ b/catalyst_voices/apps/voices/lib/pages/voting/widgets/header/voting_category_header.dart @@ -1,13 +1,14 @@ import 'package:catalyst_voices/common/ext/build_context_ext.dart'; import 'package:catalyst_voices/pages/voting/widgets/header/voting_category_picker.dart'; import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; class VotingCategoryHeader extends StatelessWidget { - final CampaignCategoryDetailsViewModel category; + final VotingHeaderCategoryData category; const VotingCategoryHeader({ super.key, @@ -21,7 +22,7 @@ class VotingCategoryHeader extends StatelessWidget { child: Stack( children: [ Positioned.fill( - child: _Background(image: category.image), + child: _Background(image: CategoryImageUrl.image(category.imageUrl)), ), Padding( padding: const EdgeInsets.fromLTRB(28, 32, 32, 44), diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_cubit.dart index 3dc79a5bf8b7..5fcb2098f6de 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_cubit.dart @@ -316,9 +316,7 @@ final class VotingCubit extends Cubit final selectedCategory = campaign?.categories.firstWhereOrNull( (e) => e.selfRef.id == selectedCategoryId, ); - final selectedCategoryViewModel = selectedCategory != null - ? CampaignCategoryDetailsViewModel.fromModel(selectedCategory) - : null; + final fundNumber = campaign?.fundNumber; final votingPowerViewModel = votingPower != null ? VotingPowerViewModel.fromModel(votingPower) @@ -327,8 +325,15 @@ final class VotingCubit extends Cubit final hasSearchQuery = filters.searchQuery != null; final categorySelectorItems = _buildCategorySelectorItems(categories, selectedCategoryId); + final header = VotingHeaderData( + showCategoryPicker: votingPhaseViewModel?.status.isActive ?? false, + category: selectedCategory != null + ? VotingHeaderCategoryData.fromModel(selectedCategory) + : null, + ); + return state.copyWith( - selectedCategory: Optional(selectedCategoryViewModel), + header: header, fundNumber: Optional(fundNumber), votingPower: votingPowerViewModel, votingPhase: Optional(votingPhaseViewModel), diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_state.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_state.dart index eaa25d295296..9aa9ccdc3efd 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_state.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_state.dart @@ -3,9 +3,35 @@ import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; +final class VotingHeaderCategoryData extends Equatable { + final String formattedName; + final String description; + final String imageUrl; + + const VotingHeaderCategoryData({ + required this.formattedName, + required this.description, + required this.imageUrl, + }); + + VotingHeaderCategoryData.fromModel(CampaignCategory data) + : this( + formattedName: data.formattedCategoryName, + description: data.description, + imageUrl: data.imageUrl, + ); + + @override + List get props => [ + formattedName, + description, + imageUrl, + ]; +} + final class VotingHeaderData extends Equatable { final bool showCategoryPicker; - final CampaignCategoryDetailsViewModel? category; + final VotingHeaderCategoryData? category; const VotingHeaderData({ this.showCategoryPicker = false, @@ -18,7 +44,7 @@ final class VotingHeaderData extends Equatable { /// The state of available proposals in the voting page. class VotingState extends Equatable { - final CampaignCategoryDetailsViewModel? selectedCategory; + final VotingHeaderData header; final int? fundNumber; final VotingPowerViewModel votingPower; final VotingPhaseProgressDetailsViewModel? votingPhase; @@ -27,7 +53,7 @@ class VotingState extends Equatable { final List categorySelectorItems; const VotingState({ - this.selectedCategory, + this.header = const VotingHeaderData(), this.fundNumber, this.votingPower = const VotingPowerViewModel(), this.votingPhase, @@ -36,16 +62,11 @@ class VotingState extends Equatable { this.categorySelectorItems = const [], }); - VotingHeaderData get header { - return VotingHeaderData( - showCategoryPicker: votingPhase?.status.isActive ?? false, - category: selectedCategory, - ); - } + bool get hasSelectedCategory => categorySelectorItems.any((element) => element.isSelected); @override List get props => [ - selectedCategory, + header, fundNumber, votingPower, votingPhase, @@ -59,7 +80,7 @@ class VotingState extends Equatable { } VotingState copyWith({ - Optional? selectedCategory, + VotingHeaderData? header, Optional? fundNumber, VotingPowerViewModel? votingPower, Optional? votingPhase, @@ -68,7 +89,7 @@ class VotingState extends Equatable { List? categorySelectorItems, }) { return VotingState( - selectedCategory: selectedCategory.dataOr(this.selectedCategory), + header: header ?? this.header, fundNumber: fundNumber.dataOr(this.fundNumber), votingPower: votingPower ?? this.votingPower, votingPhase: votingPhase.dataOr(this.votingPhase), From 40259f7b339b9c071725404b859792f86f69a7f1 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Fri, 14 Nov 2025 10:24:38 +0100 Subject: [PATCH 07/30] new proposal campaign --- .../lib/src/discovery/discovery_cubit.dart | 4 +- .../new_proposal/new_proposal_cache.dart | 33 ++++ .../new_proposal/new_proposal_cubit.dart | 173 +++++++++++++----- .../lib/src/campaign/campaign_service.dart | 8 + 4 files changed, 174 insertions(+), 44 deletions(-) create mode 100644 catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cache.dart 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 b4eb38921d95..4c56ed745b55 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 @@ -137,7 +137,9 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { } void _handleCampaignTotalAskChange(CampaignTotalAsk data) { - // + _cache = _cache.copyWith(campaignTotalAsk: Optional(data)); + + _updateCampaignState(); } void _handleProposalsChange(List proposals) { diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cache.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cache.dart new file mode 100644 index 000000000000..c26e14d8ef36 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cache.dart @@ -0,0 +1,33 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +final class NewProposalCache extends Equatable { + final SignedDocumentRef? categoryRef; + final Campaign? activeCampaign; + final CampaignTotalAsk? campaignTotalAsk; + + const NewProposalCache({ + this.categoryRef, + this.activeCampaign, + this.campaignTotalAsk, + }); + + @override + List get props => [ + categoryRef, + activeCampaign, + campaignTotalAsk, + ]; + + NewProposalCache copyWith({ + Optional? categoryRef, + Optional? activeCampaign, + Optional? campaignTotalAsk, + }) { + return NewProposalCache( + categoryRef: categoryRef.dataOr(this.categoryRef), + activeCampaign: activeCampaign.dataOr(this.activeCampaign), + campaignTotalAsk: campaignTotalAsk.dataOr(this.campaignTotalAsk), + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart index fc8db7cd088b..37d05398172c 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:catalyst_voices_blocs/src/common/bloc_error_emitter_mixin.dart'; +import 'package:catalyst_voices_blocs/src/proposal_builder/new_proposal/new_proposal_cache.dart'; import 'package:catalyst_voices_blocs/src/proposal_builder/new_proposal/new_proposal_state.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_services/catalyst_voices_services.dart'; @@ -17,6 +18,11 @@ class NewProposalCubit extends Cubit final ProposalService _proposalService; final DocumentMapper _documentMapper; + NewProposalCache _cache = const NewProposalCache(); + + StreamSubscription? _activeCampaignSub; + StreamSubscription? _activeCampaignTotalAskSub; + NewProposalCubit( this._campaignService, this._proposalService, @@ -27,6 +33,17 @@ class NewProposalCubit extends Cubit ), ); + @override + Future close() async { + await _activeCampaignSub?.cancel(); + _activeCampaignSub = null; + + await _activeCampaignTotalAskSub?.cancel(); + _activeCampaignTotalAskSub = null; + + return super.close(); + } + Future createDraft() async { try { emit(state.copyWith(isCreatingProposal: true)); @@ -70,53 +87,19 @@ class NewProposalCubit extends Cubit } Future load({SignedDocumentRef? categoryRef}) async { - try { - emit(NewProposalState.loading()); - final step = categoryRef == null - ? const CreateProposalWithoutPreselectedCategoryStep() - : const CreateProposalWithPreselectedCategoryStep(); - final campaign = await _campaignService.getActiveCampaign(); - if (campaign == null) { - throw StateError('Cannot load proposal, active campaign not found'); - } + _cache = _cache.copyWith(categoryRef: Optional(categoryRef)); - // TODO(LynxLynxx): when we have separate proposal template for generic questions use it here - // right now user can start creating proposal without selecting category. - // Right now every category have the same requirements for title so we can do a fallback for - // first category from the list. - final templateRef = campaign.categories - .cast() - .firstWhere( - (e) => e?.selfRef == categoryRef, - orElse: () => campaign.categories.firstOrNull, - ) - ?.proposalTemplateRef; - - final template = templateRef != null - ? await _proposalService.getProposalTemplate(ref: templateRef) - : null; - final titleRange = template?.title?.strLengthRange; - - final categories = campaign.categories - .map(CampaignCategoryDetailsViewModel.fromModel) - .toList(); - - final newState = state.copyWith( - isLoading: false, - step: step, - categoryRef: Optional(categoryRef), - titleLengthRange: Optional(titleRange), - categories: categories, - ); + emit(NewProposalState.loading()); - emit(newState); - } catch (error, stackTrace) { - _logger.severe('Load', error, stackTrace); + if (_cache.activeCampaign == null) { + await _getActiveCampaign(); + } - // TODO(dt-iohk): handle error state as dialog content, - // don't emit the error - emitError(LocalizedException.create(error)); + if (_activeCampaignSub == null) { + _watchActiveCampaign(); } + + await _updateCampaignCategoriesState(); } void selectCategoryStage() { @@ -152,4 +135,108 @@ class NewProposalCubit extends Cubit const stage = CreateProposalWithoutPreselectedCategoryStep(); emit(state.copyWith(step: stage)); } + + Future _getActiveCampaign() async { + try { + final campaign = await _campaignService.getActiveCampaign(); + + _handleActiveCampaignChange(campaign); + } catch (error, stackTrace) { + _logger.severe('Load', error, stackTrace); + + // TODO(dt-iohk): handle error state as dialog content, + // don't emit the error + emitError(LocalizedException.create(error)); + } + } + + void _handleActiveCampaignChange(Campaign? campaign) { + if (_cache.activeCampaign?.selfRef == campaign?.selfRef) { + return; + } + + _cache = _cache.copyWith( + activeCampaign: Optional(campaign), + campaignTotalAsk: const Optional.empty(), + ); + + unawaited(_updateCampaignCategoriesState()); + + unawaited(_activeCampaignTotalAskSub?.cancel()); + _activeCampaignTotalAskSub = null; + + if (campaign != null) _watchCampaignTotalAsk(campaign); + } + + void _handleCampaignTotalAskChange(CampaignTotalAsk data) { + _cache = _cache.copyWith(campaignTotalAsk: Optional(data)); + + unawaited(_updateCampaignCategoriesState()); + } + + Future _updateCampaignCategoriesState() async { + final campaign = _cache.activeCampaign; + final campaignTotalAsk = _cache.campaignTotalAsk ?? const CampaignTotalAsk(categoriesAsks: {}); + final preselectedCategory = _cache.categoryRef; + + // TODO(LynxLynxx): when we have separate proposal template for generic questions use it here + // right now user can start creating proposal without selecting category. + // Right now every category have the same requirements for title so we can do a fallback for + // first category from the list. + final categories = campaign?.categories ?? []; + final templateRef = categories + .cast() + .firstWhere( + (e) => e?.selfRef == preselectedCategory, + orElse: () => categories.firstOrNull, + ) + ?.proposalTemplateRef; + + final template = templateRef != null + ? await _proposalService.getProposalTemplate(ref: templateRef) + : null; + final titleRange = template?.title?.strLengthRange; + + final stateCategories = categories.map( + (category) { + final categoryTotalAsk = + campaignTotalAsk.categoriesAsks[category.selfRef] ?? + CampaignCategoryTotalAsk.zero(category.selfRef); + + return CampaignCategoryDetailsViewModel.fromModel( + category, + finalProposalsCount: categoryTotalAsk.finalProposalsCount, + totalAsk: categoryTotalAsk.totalAsk, + ); + }, + ).toList(); + + final step = _cache.categoryRef == null + ? const CreateProposalWithoutPreselectedCategoryStep() + : const CreateProposalWithPreselectedCategoryStep(); + + final newState = state.copyWith( + isLoading: false, + step: step, + categoryRef: Optional(_cache.categoryRef), + titleLengthRange: Optional(titleRange), + categories: stateCategories, + ); + + emit(newState); + } + + void _watchActiveCampaign() { + unawaited(_activeCampaignSub?.cancel()); + _activeCampaignSub = _campaignService.watchActiveCampaign + .distinct((previous, next) => previous?.selfRef != next?.selfRef) + .listen(_handleActiveCampaignChange); + } + + void _watchCampaignTotalAsk(Campaign campaign) { + _activeCampaignTotalAskSub = _campaignService + .watchCampaignTotalAsk(filters: CampaignFilters.from(campaign)) + .distinct() + .listen(_handleCampaignTotalAskChange); + } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart index 31272e18c602..39c20da592a5 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart @@ -35,6 +35,8 @@ abstract interface class CampaignService { Future getCampaignPhaseTimeline(CampaignPhaseType stage); + Future getCampaignTotalAsk({required CampaignFilters filters}); + Future getCategory(SignedDocumentRef ref); Stream watchCampaignTotalAsk({required CampaignFilters filters}); @@ -99,6 +101,12 @@ final class CampaignServiceImpl implements CampaignService { return timelineStage; } + @override + Future getCampaignTotalAsk({required CampaignFilters filters}) { + // TODO(damian-molinski): implement it. + return Future(() => const CampaignTotalAsk(categoriesAsks: {})); + } + @override Future getCategory(SignedDocumentRef ref) async { final category = await _campaignRepository.getCategory(ref); From 14806eebc7a2664043c94967fcbdbf088422e00e Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Fri, 14 Nov 2025 10:26:57 +0100 Subject: [PATCH 08/30] safe check --- .../src/proposal_builder/new_proposal/new_proposal_cubit.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart index 37d05398172c..6b38b938420c 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart @@ -223,7 +223,9 @@ class NewProposalCubit extends Cubit categories: stateCategories, ); - emit(newState); + if (!isClosed) { + emit(newState); + } } void _watchActiveCampaign() { From 5bc064d35b967fc203bb7d55f710cc7376cb2af4 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Fri, 14 Nov 2025 11:03:00 +0100 Subject: [PATCH 09/30] proposal builder category total ask --- .../proposal_builder_bloc.dart | 20 +++++++++++++++++-- .../proposal_builder_bloc_cache.dart | 5 +++++ .../lib/src/campaign/campaign_service.dart | 15 ++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/proposal_builder_bloc.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/proposal_builder_bloc.dart index 7e6b1ff56332..97cac1907e30 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/proposal_builder_bloc.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/proposal_builder_bloc.dart @@ -152,6 +152,7 @@ final class ProposalBuilderBloc extends Bloc comments, required CommentsState commentsState, @@ -177,7 +178,11 @@ final class ProposalBuilderBloc extends Bloc? comments; final AccountPublicStatus? accountPublicStatus; @@ -19,6 +20,7 @@ final class ProposalBuilderBlocCache extends Equatable { this.proposalDocument, this.proposalMetadata, this.category, + this.categoryTotalAsk, this.commentTemplate, this.comments, this.accountPublicStatus, @@ -34,6 +36,7 @@ final class ProposalBuilderBlocCache extends Equatable { proposalDocument, proposalMetadata, category, + categoryTotalAsk, commentTemplate, comments, accountPublicStatus, @@ -46,6 +49,7 @@ final class ProposalBuilderBlocCache extends Equatable { Optional? proposalDocument, Optional? proposalMetadata, Optional? category, + Optional? categoryTotalAsk, Optional? commentTemplate, Optional>? comments, Optional? accountPublicStatus, @@ -57,6 +61,7 @@ final class ProposalBuilderBlocCache extends Equatable { proposalDocument: proposalDocument.dataOr(this.proposalDocument), proposalMetadata: proposalMetadata.dataOr(this.proposalMetadata), category: category.dataOr(this.category), + categoryTotalAsk: categoryTotalAsk.dataOr(this.categoryTotalAsk), commentTemplate: commentTemplate.dataOr(this.commentTemplate), comments: comments.dataOr(this.comments), accountPublicStatus: accountPublicStatus.dataOr(this.accountPublicStatus), diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart index 39c20da592a5..5b11e3b5511b 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart @@ -39,7 +39,11 @@ abstract interface class CampaignService { Future getCategory(SignedDocumentRef ref); + Future getCategoryTotalAsk({required SignedDocumentRef ref}); + Stream watchCampaignTotalAsk({required CampaignFilters filters}); + + Stream watchCategoryTotalAsk({required SignedDocumentRef ref}); } final class CampaignServiceImpl implements CampaignService { @@ -125,9 +129,20 @@ final class CampaignServiceImpl implements CampaignService { ); } + @override + Future getCategoryTotalAsk({required SignedDocumentRef ref}) { + // TODO(damian-molinski): implement it. + return Future(() => CampaignCategoryTotalAsk.zero(ref)); + } + @override Stream watchCampaignTotalAsk({required CampaignFilters filters}) { // TODO(damian-molinski): implement it. return Stream.value(const CampaignTotalAsk(categoriesAsks: {})); } + + @override + Stream watchCategoryTotalAsk({required SignedDocumentRef ref}) { + return Stream.value(CampaignCategoryTotalAsk.zero(ref)); + } } From 6e69435b85fdecb25fabcff05b1ebb4b25f03cc3 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Fri, 14 Nov 2025 11:04:30 +0100 Subject: [PATCH 10/30] chore: missing TODO --- .../lib/src/campaign/campaign_service.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart index 5b11e3b5511b..89758c43d837 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart @@ -143,6 +143,7 @@ final class CampaignServiceImpl implements CampaignService { @override Stream watchCategoryTotalAsk({required SignedDocumentRef ref}) { + // TODO(damian-molinski): implement it. return Stream.value(CampaignCategoryTotalAsk.zero(ref)); } } From d2d0c3915bdf282e48a8993e387d2b5809daa681 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Fri, 14 Nov 2025 12:49:02 +0100 Subject: [PATCH 11/30] category details --- .../lib/pages/category/category_page.dart | 25 ++-- .../category/change_category_button.dart | 30 ++-- .../lib/routes/routing/spaces_route.dart | 2 +- .../src/category/category_detail_cubit.dart | 139 +++++++++++++----- .../category/category_detail_cubit_cache.dart | 43 ++++++ .../src/category/category_detail_state.dart | 51 +++++-- .../lib/src/discovery/discovery_cubit.dart | 2 +- 7 files changed, 213 insertions(+), 79 deletions(-) create mode 100644 catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit_cache.dart diff --git a/catalyst_voices/apps/voices/lib/pages/category/category_page.dart b/catalyst_voices/apps/voices/lib/pages/category/category_page.dart index 173ed3416368..f7783fa3f0d1 100644 --- a/catalyst_voices/apps/voices/lib/pages/category/category_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/category/category_page.dart @@ -16,9 +16,9 @@ import 'package:flutter/material.dart'; import 'package:skeletonizer/skeletonizer.dart'; class CategoryPage extends StatefulWidget { - final SignedDocumentRef categoryId; + final SignedDocumentRef categoryRef; - const CategoryPage({super.key, required this.categoryId}); + const CategoryPage({super.key, required this.categoryRef}); @override State createState() => _CategoryPageState(); @@ -106,6 +106,8 @@ class _CategoryDetailContent extends StatelessWidget { @override Widget build(BuildContext context) { + // TODO(damian-molinski): refactor it into single class object in category_detail_state.dart + // and do not rely on context.select here. return BlocSelector< CategoryDetailCubit, CategoryDetailState, @@ -114,7 +116,7 @@ class _CategoryDetailContent extends StatelessWidget { selector: (state) { return ( show: state.isLoading, - data: state.category ?? CampaignCategoryDetailsViewModel.placeholder(), + data: state.selectedCategoryDetails ?? CampaignCategoryDetailsViewModel.placeholder(), ); }, builder: (context, state) { @@ -187,7 +189,7 @@ class _CategoryPageState extends State { children: [ const _CategoryDetailContent(), _CategoryDetailError( - categoryId: widget.categoryId, + categoryId: widget.categoryRef, ), ].constrainedDelegate(), ), @@ -198,9 +200,9 @@ class _CategoryPageState extends State { void didUpdateWidget(CategoryPage oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.categoryId != oldWidget.categoryId) { + if (widget.categoryRef != oldWidget.categoryRef) { unawaited( - context.read().getCategoryDetail(widget.categoryId), + context.read().getCategoryDetail(widget.categoryRef), ); } } @@ -215,17 +217,16 @@ class _CategoryPageState extends State { @override void initState() { super.initState(); - unawaited(context.read().getCategories()); - unawaited( - context.read().getCategoryDetail(widget.categoryId), - ); - _listenForProposalRef(context.read()); + final cubit = context.read()..watchActiveCampaignCategories(); + unawaited(cubit.getCategoryDetail(widget.categoryRef)); + _listenForProposalRef(cubit); } + // TODO(damian-molinski): refactor it to signal pattern void _listenForProposalRef(CategoryDetailCubit cubit) { // listen for updates _categoryRefSub = cubit.stream - .map((event) => event.category?.ref) + .map((event) => event.selectedCategoryRef) .distinct() .listen(_onCategoryRefChanged); } diff --git a/catalyst_voices/apps/voices/lib/pages/category/change_category_button.dart b/catalyst_voices/apps/voices/lib/pages/category/change_category_button.dart index b4fe7fc0aad3..82ce013348a1 100644 --- a/catalyst_voices/apps/voices/lib/pages/category/change_category_button.dart +++ b/catalyst_voices/apps/voices/lib/pages/category/change_category_button.dart @@ -14,28 +14,20 @@ class ChangeCategoryButton extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocSelector< - CategoryDetailCubit, - CategoryDetailState, - List> - >( - selector: (state) { - final selectedCategory = state.category?.ref; - - return state.categories - .map( - (e) => DropdownMenuViewModel( - value: ProposalsRefCategoryFilter(ref: e.ref), - name: e.formattedName, - isSelected: e.ref == selectedCategory, - ), - ) - .toList(); - }, + return BlocSelector( + selector: (state) => state.picker, builder: (context, state) { return CampaignCategoryPicker( onSelected: (value) => unawaited(_changeCategory(context, value)), - items: state, + items: state.items.map( + (item) { + return DropdownMenuViewModel( + value: ProposalsRefCategoryFilter(ref: item.ref), + name: item.name, + isSelected: item.isSelected, + ); + }, + ).toList(), buttonBuilder: ( 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 a64afe5820dc..852eff0a35d5 100644 --- a/catalyst_voices/apps/voices/lib/routes/routing/spaces_route.dart +++ b/catalyst_voices/apps/voices/lib/routes/routing/spaces_route.dart @@ -33,7 +33,7 @@ final class CategoryDetailRoute extends GoRouteData with FadePageTransitionMixin @override Widget build(BuildContext context, GoRouterState state) { return CategoryPage( - categoryId: SignedDocumentRef(id: categoryId), + categoryRef: SignedDocumentRef(id: categoryId), ); } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit.dart index 3cdc5b9859a5..d38540a387b3 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit.dart @@ -1,66 +1,68 @@ +import 'dart:async'; + import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_blocs/src/category/category_detail_cubit_cache.dart'; 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'; + +final _logger = Logger('CategoryDetailCubit'); /// Manages the category detail. /// Allows to get the category detail and list of categories. class CategoryDetailCubit extends Cubit { final CampaignService _campaignService; - Campaign? _cachedCampaign; + + CategoryDetailCubitCache _cache = const CategoryDetailCubitCache(); + + StreamSubscription?>? _categoriesSub; + StreamSubscription? _selectedCategoryTotalAskSub; CategoryDetailCubit( this._campaignService, ) : super(const CategoryDetailState(isLoading: true)); - Future getCategories() async { - if (_cachedCampaign != null) return; + @override + Future close() async { + await _categoriesSub?.cancel(); + _categoriesSub = null; - if (!state.isLoading) { - emit(state.copyWith(isLoading: true)); - } + await _selectedCategoryTotalAskSub?.cancel(); + _selectedCategoryTotalAskSub = null; - final campaign = await _campaignService.getActiveCampaign(); - if (campaign == null) { - _cachedCampaign = campaign; - return emit( - state.copyWith( - isLoading: false, - error: const Optional.of(LocalizedUnknownException()), - ), - ); + await super.close(); + } + + Future getCategoryDetail(SignedDocumentRef categoryRef) async { + if (categoryRef.id == _cache.selectedCategoryRef?.id) { + emit(state.copyWith(isLoading: false)); + return; } - final categoriesModels = campaign.categories - .map(CampaignCategoryDetailsViewModel.fromModel) - .toList(); + await _selectedCategoryTotalAskSub?.cancel(); + _selectedCategoryTotalAskSub = null; - emit( - state.copyWith( - categories: categoriesModels, - error: const Optional.empty(), - ), + _cache = _cache.copyWith( + selectedCategoryRef: Optional(categoryRef), + selectedCategory: const Optional.empty(), + selectedCategoryTotalAsk: const Optional.empty(), ); - } - Future getCategoryDetail(SignedDocumentRef categoryId) async { - if (categoryId.id == state.category?.ref.id) { - return emit(state.copyWith(isLoading: false)); - } + emit(state.copyWith(selectedCategoryRef: Optional(categoryRef))); + _updateCategoriesState(); if (!state.isLoading) { emit(state.copyWith(isLoading: true)); } try { - final category = await _campaignService.getCategory(categoryId); - emit( - state.copyWith( - isLoading: false, - category: CampaignCategoryDetailsViewModel.fromModel(category), - error: const Optional.empty(), - ), - ); + final category = await _campaignService.getCategory(categoryRef); + _cache = _cache.copyWith(selectedCategory: Optional(category)); + + _watchCategoryTotalAsk(categoryRef); + _updateSelectedCategoryState(); } catch (error) { emit( state.copyWith( @@ -70,4 +72,69 @@ class CategoryDetailCubit extends Cubit { ); } } + + void watchActiveCampaignCategories() { + unawaited(_categoriesSub?.cancel()); + _categoriesSub = _campaignService.watchActiveCampaign + .map((event) => event?.categories) + .distinct(listEquals) + .listen(_handleCategoriesChange); + } + + void _handleCategoriesChange(List? categories) { + _cache = _cache.copyWith(categories: Optional(categories)); + _updateCategoriesState(); + } + + void _handleCategoryTotalAskChange(CampaignCategoryTotalAsk data) { + _logger.finest('Category total ask changed: $data'); + _cache = _cache.copyWith(selectedCategoryTotalAsk: Optional(data)); + _updateSelectedCategoryState(); + } + + void _updateCategoriesState() { + final selectedCategoryRef = _cache.selectedCategoryRef; + final categories = _cache.categories ?? []; + + final items = categories.map( + (category) { + return CategoryDetailStatePickerItem( + ref: category.selfRef, + name: category.formattedCategoryName, + isSelected: category.selfRef == selectedCategoryRef, + ); + }, + ).toList(); + + emit(state.copyWith(picker: CategoryDetailStatePicker(items: items))); + } + + void _updateSelectedCategoryState() { + final selectedCategory = _cache.selectedCategory; + final selectedCategoryTotalAsk = _cache.selectedCategoryTotalAsk; + + final selectedCategoryState = selectedCategory != null + ? CampaignCategoryDetailsViewModel.fromModel( + selectedCategory, + finalProposalsCount: selectedCategoryTotalAsk?.finalProposalsCount ?? 0, + totalAsk: selectedCategoryTotalAsk?.totalAsk ?? MultiCurrencyAmount.zero(), + ) + : null; + + final updatedState = state.copyWith( + isLoading: selectedCategory == null || selectedCategoryTotalAsk == null, + selectedCategoryDetails: Optional(selectedCategoryState), + error: const Optional.empty(), + ); + + emit(updatedState); + } + + void _watchCategoryTotalAsk(SignedDocumentRef ref) { + unawaited(_selectedCategoryTotalAskSub?.cancel()); + _selectedCategoryTotalAskSub = _campaignService + .watchCategoryTotalAsk(ref: ref) + .distinct() + .listen(_handleCategoryTotalAskChange); + } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit_cache.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit_cache.dart new file mode 100644 index 000000000000..e3c10bf72f42 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit_cache.dart @@ -0,0 +1,43 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +final class CategoryDetailCubitCache extends Equatable { + final Campaign? activeCampaign; + final List? categories; + final SignedDocumentRef? selectedCategoryRef; + final CampaignCategory? selectedCategory; + final CampaignCategoryTotalAsk? selectedCategoryTotalAsk; + + const CategoryDetailCubitCache({ + this.activeCampaign, + this.categories, + this.selectedCategoryRef, + this.selectedCategory, + this.selectedCategoryTotalAsk, + }); + + @override + List get props => [ + activeCampaign, + categories, + selectedCategoryRef, + selectedCategory, + selectedCategoryTotalAsk, + ]; + + CategoryDetailCubitCache copyWith({ + Optional? activeCampaign, + Optional>? categories, + Optional? selectedCategoryRef, + Optional? selectedCategory, + Optional? selectedCategoryTotalAsk, + }) { + return CategoryDetailCubitCache( + activeCampaign: activeCampaign.dataOr(this.activeCampaign), + categories: categories.dataOr(this.categories), + selectedCategoryRef: selectedCategoryRef.dataOr(this.selectedCategoryRef), + selectedCategory: selectedCategory.dataOr(this.selectedCategory), + selectedCategoryTotalAsk: selectedCategoryTotalAsk.dataOr(this.selectedCategoryTotalAsk), + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_state.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_state.dart index 0997a4c5d156..cb7428600bbd 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_state.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_state.dart @@ -3,37 +3,68 @@ import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:equatable/equatable.dart'; class CategoryDetailState extends Equatable { - final CampaignCategoryDetailsViewModel? category; - final List categories; + final SignedDocumentRef? selectedCategoryRef; + final CampaignCategoryDetailsViewModel? selectedCategoryDetails; + final CategoryDetailStatePicker picker; final bool isLoading; final LocalizedException? error; const CategoryDetailState({ - this.category, - this.categories = const [], + this.selectedCategoryRef, + this.selectedCategoryDetails, + this.picker = const CategoryDetailStatePicker(), this.isLoading = false, this.error, }); @override List get props => [ - category, - categories, + selectedCategoryRef, + selectedCategoryDetails, + picker, isLoading, error, ]; CategoryDetailState copyWith({ - CampaignCategoryDetailsViewModel? category, - List? categories, + Optional? selectedCategoryRef, + Optional? selectedCategoryDetails, + CategoryDetailStatePicker? picker, bool? isLoading, Optional? error, }) { return CategoryDetailState( - category: category ?? this.category, - categories: categories ?? this.categories, + selectedCategoryRef: selectedCategoryRef.dataOr(this.selectedCategoryRef), + selectedCategoryDetails: selectedCategoryDetails.dataOr(this.selectedCategoryDetails), + picker: picker ?? this.picker, isLoading: isLoading ?? this.isLoading, error: error.dataOr(this.error), ); } } + +final class CategoryDetailStatePicker extends Equatable { + final List items; + + const CategoryDetailStatePicker({ + this.items = const [], + }); + + @override + List get props => [items]; +} + +final class CategoryDetailStatePickerItem extends Equatable { + final SignedDocumentRef ref; + final String name; + final bool isSelected; + + const CategoryDetailStatePickerItem({ + required this.ref, + required this.name, + required this.isSelected, + }); + + @override + List get props => [ref, name, isSelected]; +} 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 4c56ed745b55..16736b976f76 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 @@ -137,8 +137,8 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { } void _handleCampaignTotalAskChange(CampaignTotalAsk data) { + _logger.finest('Campaign total ask changed: $data'); _cache = _cache.copyWith(campaignTotalAsk: Optional(data)); - _updateCampaignState(); } From 06ae09543d442c478ab631df5fcda8d35c153c4a Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Mon, 17 Nov 2025 13:20:48 +0100 Subject: [PATCH 12/30] watch proposals template total ask --- .../voices/lib/dependency/dependencies.dart | 8 +- .../campaign/campaign_category_total_ask.dart | 24 + .../lib/src/catalyst_voices_models.dart | 2 + .../enums/document_property_format.dart | 67 +- .../specialized/proposal_template.dart | 15 +- .../lib/src/money/money_format.dart | 83 ++ .../data/proposal_template_total_ask.dart | 17 + .../proposal/proposal_template_currency.dart | 18 + .../lib/src/campaign/campaign_repository.dart | 20 +- .../src/database/dao/documents_v2_dao.dart | 55 ++ .../src/database/dao/proposals_v2_dao.dart | 118 +++ .../database_documents_data_source.dart | 21 + .../proposal_document_data_local_source.dart | 17 +- .../lib/src/proposal/proposal_repository.dart | 13 + .../proposal/proposal_template_factory.dart | 1 + .../database/connection/test_connection.dart | 13 +- .../database/dao/documents_v2_dao_test.dart | 392 ++++++++- .../database/dao/proposals_v2_dao_test.dart | 765 ++++++++++++++++++ .../src/database/logging_db_interceptor.dart | 214 +++++ .../lib/src/campaign/campaign_service.dart | 96 ++- 20 files changed, 1869 insertions(+), 90 deletions(-) create mode 100644 catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/money_format.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_template_total_ask.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_template_currency.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/logging_db_interceptor.dart diff --git a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart index b18aa2de1b47..45e042528420 100644 --- a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart +++ b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart @@ -272,7 +272,13 @@ final class Dependencies extends DependencyProvider { get(), ); }) - ..registerLazySingleton(CampaignRepository.new) + ..registerLazySingleton( + () { + return CampaignRepository( + get(), + ); + }, + ) ..registerLazySingleton(() { return DocumentRepository( get(), diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category_total_ask.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category_total_ask.dart index 0c342c18195f..238634b53fa0 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category_total_ask.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category_total_ask.dart @@ -22,4 +22,28 @@ final class CampaignCategoryTotalAsk extends Equatable { ]; MultiCurrencyAmount get totalAsk => MultiCurrencyAmount.list(money); + + CampaignCategoryTotalAsk operator +(CampaignCategoryTotalAsk other) { + assert(ref == other.ref, 'Refs do not match'); + + final finalProposalsCount = this.finalProposalsCount + other.finalProposalsCount; + final money = [...this.money, ...other.money]; + + return copyWith( + finalProposalsCount: finalProposalsCount, + money: money, + ); + } + + CampaignCategoryTotalAsk copyWith({ + DocumentRef? ref, + int? finalProposalsCount, + List? money, + }) { + return CampaignCategoryTotalAsk( + ref: ref ?? this.ref, + finalProposalsCount: finalProposalsCount ?? this.finalProposalsCount, + money: money ?? this.money, + ); + } } 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 4e4c863a96b6..3ae910bf0b03 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 @@ -76,6 +76,7 @@ export 'logging/logging_settings.dart'; export 'money/currencies.dart'; export 'money/currency.dart'; export 'money/money.dart'; +export 'money/money_format.dart'; export 'money/multi_currency_amount.dart'; export 'pagination/page.dart'; export 'pagination/page_request.dart'; @@ -83,6 +84,7 @@ export 'permissions/exceptions/permission_exceptions.dart'; export 'proposal/core_proposal.dart'; export 'proposal/data/joined_proposal_brief_data.dart'; export 'proposal/data/proposal_brief_data.dart'; +export 'proposal/data/proposal_template_total_ask.dart'; export 'proposal/detail_proposal.dart'; export 'proposal/exception/proposal_limit_reached_exception.dart'; export 'proposal/proposal.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/enums/document_property_format.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/enums/document_property_format.dart index 8622c2ee03b4..ac66666f2576 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/enums/document_property_format.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/enums/document_property_format.dart @@ -28,74 +28,19 @@ base class DocumentCurrencyFormat extends DocumentPropertyFormat { @override List get props => super.props + [currency, moneyUnits]; - /// Parses the [DocumentCurrencyFormat] from a [format]. - /// Returns `null` if format is unrecognized. - /// - /// Format: - /// - token|fiat[:$brand]:$code[:$cent] - /// - /// Examples: - /// - token:cardano:ada - /// - token:cardano:ada:lovelace - /// - token:usdm - /// - token:usdm:cent - /// - fiat:usd - /// - fiat:usd:cent - /// - fiat:eur - /// - fiat:eur:cent + /// Parses the [DocumentCurrencyFormat] from a [format] unit [MoneyFormat]. static DocumentCurrencyFormat? parse(String format) { - final parts = format.split(':'); - return switch (parts) { - [final type, _, final code, final minor] - when _isValidType(type) && _isValidMinorUnits(minor) => - _createFormat(format, code, MoneyUnits.minorUnits), - - [final type, final code, final minor] when _isValidType(type) && _isValidMinorUnits(minor) => - _createFormat(format, code, MoneyUnits.minorUnits), - - [final type, _, final code] when _isValidType(type) => _createFormat( - format, - code, - MoneyUnits.majorUnits, - ), - - [final type, final code] when _isValidType(type) => _createFormat( - format, - code, - MoneyUnits.majorUnits, - ), - _ => null, - }; - } - - static DocumentCurrencyFormat? _createFormat( - String format, - String currencyCode, - MoneyUnits moneyUnits, - ) { - final currency = Currency.fromCode(currencyCode); - if (currency == null) { + final moneyFormat = MoneyFormat.parse(format); + if (moneyFormat == null) { return null; } + return DocumentCurrencyFormat( format, - currency: currency, - moneyUnits: moneyUnits, + currency: moneyFormat.currency, + moneyUnits: moneyFormat.moneyUnits, ); } - - /// Checks if a string identifies a minor currency unit. - static bool _isValidMinorUnits(String minorUnits) { - return switch (minorUnits) { - 'cent' || 'penny' || 'lovelace' || 'sat' || 'wei' => true, - _ => false, - }; - } - - /// Checks if the type is 'fiat' or 'token'. - static bool _isValidType(String type) { - return type == 'fiat' || type == 'token'; - } } final class DocumentDropdownSingleSelectFormat extends DocumentPropertyFormat { diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/specialized/proposal_template.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/specialized/proposal_template.dart index 60bde983410f..2c2ce38a19b3 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/specialized/proposal_template.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/specialized/proposal_template.dart @@ -11,16 +11,21 @@ final class ProposalTemplate extends Equatable { required this.schema, }); - DocumentStringSchema? get title { - final property = schema.getPropertySchema(ProposalDocument.titleNodeId); - return property is DocumentStringSchema ? property : null; - } - @override List get props => [ metadata, schema, ]; + + DocumentCurrencySchema? get requestedFunds { + final property = schema.getPropertySchema(ProposalDocument.requestedFundsNodeId); + return property is DocumentCurrencySchema ? property : null; + } + + DocumentStringSchema? get title { + final property = schema.getPropertySchema(ProposalDocument.titleNodeId); + return property is DocumentStringSchema ? property : null; + } } final class ProposalTemplateMetadata extends DocumentMetadata { diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/money_format.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/money_format.dart new file mode 100644 index 000000000000..d645b560e2d0 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/money_format.dart @@ -0,0 +1,83 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +final class MoneyFormat extends Equatable { + final Currency currency; + final MoneyUnits moneyUnits; + + const MoneyFormat({ + required this.currency, + required this.moneyUnits, + }); + + @override + List get props => [ + currency, + moneyUnits, + ]; + + /// Parses the [MoneyFormat] from a [format]. + /// Returns `null` if format is unrecognized. + /// + /// Format: + /// - token|fiat[:$brand]:$code[:$cent] + /// + /// Examples: + /// - token:cardano:ada + /// - token:cardano:ada:lovelace + /// - token:usdm + /// - token:usdm:cent + /// - fiat:usd + /// - fiat:usd:cent + /// - fiat:eur + /// - fiat:eur:cent + static MoneyFormat? parse(String format) { + final parts = format.split(':'); + return switch (parts) { + [final type, _, final code, final minor] + when _isValidType(type) && _isValidMinorUnits(minor) => + _createFormat(code, MoneyUnits.minorUnits), + + [final type, final code, final minor] when _isValidType(type) && _isValidMinorUnits(minor) => + _createFormat(code, MoneyUnits.minorUnits), + + [final type, _, final code] when _isValidType(type) => _createFormat( + code, + MoneyUnits.majorUnits, + ), + + [final type, final code] when _isValidType(type) => _createFormat( + code, + MoneyUnits.majorUnits, + ), + _ => null, + }; + } + + static MoneyFormat? _createFormat( + String currencyCode, + MoneyUnits moneyUnits, + ) { + final currency = Currency.fromCode(currencyCode); + if (currency == null) { + return null; + } + return MoneyFormat( + currency: currency, + moneyUnits: moneyUnits, + ); + } + + /// Checks if a string identifies a minor currency unit. + static bool _isValidMinorUnits(String minorUnits) { + return switch (minorUnits) { + 'cent' || 'penny' || 'lovelace' || 'sat' || 'wei' => true, + _ => false, + }; + } + + /// Checks if the type is 'fiat' or 'token'. + static bool _isValidType(String type) { + return type == 'fiat' || type == 'token'; + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_template_total_ask.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_template_total_ask.dart new file mode 100644 index 000000000000..e1775095d271 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_template_total_ask.dart @@ -0,0 +1,17 @@ +import 'package:equatable/equatable.dart'; + +final class ProposalTemplateTotalAsk extends Equatable { + final int totalAsk; + final int finalProposalsCount; + + const ProposalTemplateTotalAsk({ + required this.totalAsk, + required this.finalProposalsCount, + }); + + @override + List get props => [ + totalAsk, + finalProposalsCount, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_template_currency.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_template_currency.dart new file mode 100644 index 000000000000..a500d97c32f0 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_template_currency.dart @@ -0,0 +1,18 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +final class ProposalTemplateMoneyFormats extends Equatable { + final DocumentRef ref; + final Map formats; + + const ProposalTemplateMoneyFormats({ + required this.ref, + required this.formats, + }); + + @override + List get props => [ + ref, + formats, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/campaign/campaign_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/campaign/campaign_repository.dart index 540d26d46fa2..43f688bfc1b6 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/campaign/campaign_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/campaign/campaign_repository.dart @@ -1,19 +1,27 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/src/document/source/proposal_document_data_local_source.dart'; import 'package:collection/collection.dart'; /// Allows access to campaign data, categories, and timeline. abstract interface class CampaignRepository { - const factory CampaignRepository() = CampaignRepositoryImpl; + const factory CampaignRepository(ProposalDocumentDataLocalSource source) = CampaignRepositoryImpl; Future getCampaign({ required String id, }); Future getCategory(SignedDocumentRef ref); + + Stream> watchProposalTemplatesTotalTask({ + required CampaignFilters filters, + required NodeId nodeId, + }); } final class CampaignRepositoryImpl implements CampaignRepository { - const CampaignRepositoryImpl(); + final ProposalDocumentDataLocalSource _source; + + const CampaignRepositoryImpl(this._source); @override Future getCampaign({ @@ -34,4 +42,12 @@ final class CampaignRepositoryImpl implements CampaignRepository { .expand((element) => element.categories) .firstWhereOrNull((e) => e.selfRef.id == ref.id); } + + @override + Stream> watchProposalTemplatesTotalTask({ + required CampaignFilters filters, + required NodeId nodeId, + }) { + return _source.watchProposalTemplatesTotalTask(filters: filters, nodeId: nodeId); + } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart index 432d55821d41..ad99619c382d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart @@ -45,6 +45,23 @@ abstract interface class DocumentsV2Dao { /// [entries] is a list of DocumentEntity instances. /// Uses insertOrIgnore to skip on primary key conflicts ({id, ver}). Future saveAll(List entries); + + /// Watches for a list of documents that match the given criteria. + /// + /// This method returns a stream that emits a new list of documents whenever + /// the underlying data changes. + /// - [type]: Optional filter to only include documents of a specific [DocumentType]. + /// - [filters]: Optional campaign filter. + /// - [latestOnly] is true only newest version per id is returned. + /// - [limit]: The maximum number of documents to return. + /// - [offset]: The number of documents to skip for pagination. + Stream> watchDocuments({ + DocumentType? type, + CampaignFilters? filters, + bool latestOnly, + int limit, + int offset, + }); } @DriftAccessor( @@ -155,4 +172,42 @@ class DriftDocumentsV2Dao extends DatabaseAccessor } }); } + + @override + Stream> watchDocuments({ + DocumentType? type, + CampaignFilters? filters, + bool latestOnly = false, + int limit = 200, + int offset = 0, + }) { + final effectiveLimit = limit.clamp(0, 999); + + final query = select(documentsV2); + + if (filters != null) { + query.where((tbl) => tbl.categoryId.isIn(filters.categoriesIds)); + } + + if (type != null) { + query.where((tbl) => tbl.type.equalsValue(type)); + } + + if (latestOnly) { + final inner = alias(documentsV2, 'inner'); + + query.where((tbl) { + final maxCreatedAt = subqueryExpression( + selectOnly(inner) + ..addColumns([inner.createdAt.max()]) + ..where(inner.id.equalsExp(tbl.id)), + ); + return tbl.createdAt.equalsExp(maxCreatedAt); + }); + } + + query.limit(effectiveLimit, offset: offset); + + return query.watch(); + } } 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 f688b4132c4e..10cd5a48ad1b 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 @@ -117,6 +117,23 @@ class DriftProposalsV2Dao extends DatabaseAccessor ); } + @override + Future> getProposalTemplatesTotalTask({ + required CampaignFilters filters, + required NodeId nodeId, + }) async { + if (filters.categoriesIds.isEmpty) { + return {}; + } + + final entries = await _queryProposalTemplatesTotalTask( + filters: filters, + nodeId: nodeId, + ).get(); + + return Map.fromEntries(entries); + } + @override Future getVisibleProposalsCount({ ProposalsFiltersV2 filters = const ProposalsFiltersV2(), @@ -182,6 +199,21 @@ class DriftProposalsV2Dao extends DatabaseAccessor ); } + @override + Stream> watchProposalTemplatesTotalTask({ + required CampaignFilters filters, + required NodeId nodeId, + }) { + if (filters.categoriesIds.isEmpty) { + return Stream.value({}); + } + + return _queryProposalTemplatesTotalTask( + filters: filters, + nodeId: nodeId, + ).watch().map(Map.fromEntries); + } + @override Stream watchVisibleProposalsCount({ ProposalsFiltersV2 filters = const ProposalsFiltersV2(), @@ -440,6 +472,82 @@ class DriftProposalsV2Dao extends DatabaseAccessor return input.replaceAll("'", "''"); } + Selectable> _queryProposalTemplatesTotalTask({ + required CampaignFilters filters, + required NodeId nodeId, + }) { + final escapedCategories = filters.categoriesIds + .map((id) => "'${_escapeSqlString(id)}'") + .join(', '); + + final query = + ''' + WITH latest_actions AS ( + SELECT ref_id, MAX(ver) as max_action_ver + FROM documents_v2 + WHERE type = ? + GROUP BY ref_id + ), + action_status AS ( + SELECT + a.ref_id, + 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 = ? + ), + effective_final_proposals AS ( + SELECT + ast.ref_id as id, + ast.ref_ver as ver + FROM action_status ast + WHERE ast.action_type = 'final' + AND ast.ref_ver IS NOT NULL + AND ast.ref_ver != '' + ) + SELECT + p.template_id, + p.template_ver, + SUM(COALESCE(CAST(json_extract(p.content, '\$.${nodeId.value}') AS INTEGER), 0)) as total_ask, + COUNT(*) as final_proposals_count + FROM documents_v2 p + INNER JOIN effective_final_proposals efp ON p.id = efp.id AND p.ver = efp.ver + WHERE p.type = ? + AND p.category_id IN ($escapedCategories) + AND p.template_id IS NOT NULL + AND p.template_ver IS NOT NULL + GROUP BY p.template_id, p.template_ver + '''; + + return customSelect( + query, + variables: [ + Variable.withString(DocumentType.proposalActionDocument.uuid), + Variable.withString(DocumentType.proposalActionDocument.uuid), + Variable.withString(DocumentType.proposalDocument.uuid), + ], + readsFrom: {documentsV2}, + ).map((row) { + final templateId = row.read('template_id'); + final templateVer = row.read('template_ver'); + final totalAsk = row.read('total_ask'); + final finalProposalsCount = row.read('final_proposals_count'); + + final ref = SignedDocumentRef( + id: templateId, + version: templateVer, + ); + + final value = ProposalTemplateTotalAsk( + totalAsk: totalAsk, + finalProposalsCount: finalProposalsCount, + ); + + return MapEntry(ref, value); + }); + } + /// Fetches a page of visible proposals using multi-stage CTE logic. /// /// **CTE Pipeline:** @@ -723,6 +831,11 @@ abstract interface class ProposalsV2Dao { ProposalsFiltersV2 filters, }); + Future> getProposalTemplatesTotalTask({ + required CampaignFilters filters, + required NodeId nodeId, + }); + /// Counts the total number of visible proposals that match the given filters. /// /// This method respects the same status handling logic as [getProposalsBriefPage], @@ -774,6 +887,11 @@ abstract interface class ProposalsV2Dao { ProposalsFiltersV2 filters, }); + Stream> watchProposalTemplatesTotalTask({ + required CampaignFilters filters, + required NodeId nodeId, + }); + /// Watches for changes and emits the total count of visible proposals. /// /// Provides a reactive stream that emits a new integer count whenever the 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 bb665a639d39..a4f471f215ef 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 @@ -10,6 +10,7 @@ import 'package:catalyst_voices_repositories/src/document/source/proposal_docume import 'package:catalyst_voices_repositories/src/proposal/proposal_document_factory.dart'; import 'package:catalyst_voices_repositories/src/proposal/proposal_template_factory.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/foundation.dart'; import 'package:rxdart/rxdart.dart'; final class DatabaseDocumentsDataSource @@ -222,6 +223,26 @@ final class DatabaseDocumentsDataSource .map((page) => page.map((e) => e.toModel())); } + @override + Stream> watchProposalTemplates({ + required CampaignFilters filters, + }) { + return _database.documentsV2Dao + .watchDocuments(type: DocumentType.proposalTemplate, filters: filters) + .distinct(listEquals) + .map((event) => event.map((e) => e.toModel()).toList()); + } + + @override + Stream> watchProposalTemplatesTotalTask({ + required CampaignFilters filters, + required NodeId nodeId, + }) { + return _database.proposalsV2Dao + .watchProposalTemplatesTotalTask(filters: filters, nodeId: nodeId) + .distinct(); + } + @override Stream watchRefToDocumentData({ required DocumentRef refTo, 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 2ad7bd35d77b..ca57472f9b64 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 @@ -31,17 +31,26 @@ abstract interface class ProposalDocumentDataLocalSource { ProposalsFiltersV2 filters, }); - Stream watchProposalsCountV2({ - ProposalsFiltersV2 filters, - }); - Stream watchProposalsCount({ required ProposalsCountFilters filters, }); + Stream watchProposalsCountV2({ + ProposalsFiltersV2 filters, + }); + Stream> watchProposalsPage({ required PageRequest request, required ProposalsFilters filters, required ProposalsOrder order, }); + + Stream> watchProposalTemplates({ + required CampaignFilters filters, + }); + + Stream> watchProposalTemplatesTotalTask({ + required CampaignFilters filters, + required NodeId nodeId, + }); } 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 5402985e6718..bfc50c44b47b 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 @@ -113,6 +113,10 @@ abstract interface class ProposalRepository { required ProposalsOrder order, }); + Stream> watchProposalTemplates({ + required CampaignFilters filters, + }); + Stream> watchUserProposals({ required CatalystId authorId, }); @@ -378,6 +382,15 @@ final class ProposalRepositoryImpl implements ProposalRepository { .map((value) => value.map(_buildProposalData)); } + @override + Stream> watchProposalTemplates({ + required CampaignFilters filters, + }) { + return _proposalsLocalSource + .watchProposalTemplates(filters: filters) + .map((event) => event.map(ProposalTemplateFactory.create).toList()); + } + @override Stream> watchUserProposals({ required CatalystId authorId, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_template_factory.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_template_factory.dart index 4e4162313739..cf72974beb8a 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_template_factory.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_template_factory.dart @@ -12,6 +12,7 @@ abstract final class ProposalTemplateFactory { final metadata = ProposalTemplateMetadata( selfRef: documentData.metadata.selfRef, + categoryId: documentData.metadata.categoryId, ); final contentData = documentData.content.data; diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/connection/test_connection.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/connection/test_connection.dart index 2505c2ac9aaa..329a1af31118 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/connection/test_connection.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/connection/test_connection.dart @@ -3,12 +3,21 @@ import 'package:drift/drift.dart'; import '../executor/unsupported.dart' if (dart.library.js_interop) '../executor/web.dart' if (dart.library.ffi) '../executor/native.dart'; +import '../logging_db_interceptor.dart'; -Future buildTestConnection() async { +Future buildTestConnection({ + bool logQueries = false, +}) async { final executor = await buildExecutor(); - return DatabaseConnection( + var connection = DatabaseConnection( executor, closeStreamsSynchronously: true, ); + + if (logQueries) { + connection = connection.interceptWith(LoggingDbInterceptor()); + } + + return connection; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart index a5b1deaf3fd9..ee0969b625c1 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_redundant_argument_values + import 'package:catalyst_voices_dev/catalyst_voices_dev.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; @@ -502,6 +504,394 @@ void main() { expect(saved[0].content.data['key'], 'original'); }); }); + + group('watchDocuments', () { + test('emits all documents when no filters applied', () async { + final doc1 = _createTestDocumentEntity(id: 'id1', type: DocumentType.proposalDocument); + final doc2 = _createTestDocumentEntity(id: 'id2', type: DocumentType.proposalTemplate); + final doc3 = _createTestDocumentEntity( + id: 'id3', + type: DocumentType.proposalActionDocument, + ); + + await dao.saveAll([doc1, doc2, doc3]); + + final stream = dao.watchDocuments(); + + await expectLater( + stream, + emits(hasLength(3)), + ); + }); + + test('filters documents by type', () async { + final proposal1 = _createTestDocumentEntity(id: 'id1', type: DocumentType.proposalDocument); + final proposal2 = _createTestDocumentEntity(id: 'id2', type: DocumentType.proposalDocument); + final template = _createTestDocumentEntity(id: 'id3', type: DocumentType.proposalTemplate); + + await dao.saveAll([proposal1, proposal2, template]); + + final stream = dao.watchDocuments(type: DocumentType.proposalDocument); + + await expectLater( + stream, + emits( + predicate>((docs) { + return docs.length == 2 && docs.every((d) => d.type == DocumentType.proposalDocument); + }), + ), + ); + }); + + test('respects limit parameter', () async { + final docs = List.generate( + 10, + (i) => _createTestDocumentEntity(id: 'id$i', type: DocumentType.proposalDocument), + ); + + await dao.saveAll(docs); + + final stream = dao.watchDocuments(limit: 5); + + await expectLater( + stream, + emits(hasLength(5)), + ); + }); + + test('respects offset parameter', () async { + final docs = List.generate( + 10, + (i) => _createTestDocumentEntity(id: 'id$i', type: DocumentType.proposalDocument), + ); + + await dao.saveAll(docs); + + final streamFirst = dao.watchDocuments(limit: 5, offset: 0); + final streamSecond = dao.watchDocuments(limit: 5, offset: 5); + + final firstBatch = await streamFirst.first; + final secondBatch = await streamSecond.first; + + expect(firstBatch.length, 5); + expect(secondBatch.length, 5); + expect( + firstBatch + .map((d) => d.id) + .toSet() + .intersection( + secondBatch.map((d) => d.id).toSet(), + ), + isEmpty, + ); + }); + + test('clamps limit to 999 when exceeds maximum', () async { + final docs = List.generate( + 1000, + (i) => _createTestDocumentEntity(id: 'id$i', type: DocumentType.proposalDocument), + ); + + await dao.saveAll(docs); + + final stream = dao.watchDocuments(limit: 1500); + + await expectLater( + stream, + emits(hasLength(999)), + ); + }); + + test('handles limit of 0', () async { + final doc = _createTestDocumentEntity(id: 'id1', type: DocumentType.proposalDocument); + + await dao.save(doc); + + final stream = dao.watchDocuments(limit: 0); + + await expectLater( + stream, + emits(isEmpty), + ); + }); + + test('emits empty list when no documents exist', () async { + final stream = dao.watchDocuments(); + + await expectLater( + stream, + emits(isEmpty), + ); + }); + + test('emits empty list when type filter matches nothing', () async { + final doc = _createTestDocumentEntity(id: 'id1', type: DocumentType.proposalDocument); + + await dao.save(doc); + + final stream = dao.watchDocuments(type: DocumentType.proposalTemplate); + + await expectLater( + stream, + emits(isEmpty), + ); + }); + + test('emits new values when documents are added', () async { + final stream = dao.watchDocuments(); + + final doc1 = _createTestDocumentEntity(id: 'id1', type: DocumentType.proposalDocument); + final doc2 = _createTestDocumentEntity(id: 'id2', type: DocumentType.proposalDocument); + + final expectation = expectLater( + stream, + emitsInOrder([ + isEmpty, + hasLength(1), + hasLength(2), + ]), + ); + + await pumpEventQueue(); + await dao.save(doc1); + await pumpEventQueue(); + await dao.save(doc2); + await pumpEventQueue(); + + await expectation; + }); + + test('does not emit duplicate when same document saved twice', () async { + final doc = _createTestDocumentEntity(id: 'id1', type: DocumentType.proposalDocument); + + await dao.save(doc); + + final stream = dao.watchDocuments(); + + final expectation = expectLater( + stream, + emitsInOrder([ + hasLength(1), + hasLength(1), + ]), + ); + + await pumpEventQueue(); + await dao.save(doc); + await pumpEventQueue(); + + await expectation; + }); + + test('combines type filter with limit and offset', () async { + final proposals = List.generate( + 20, + (i) => _createTestDocumentEntity(id: 'proposal$i', type: DocumentType.proposalDocument), + ); + + final templates = List.generate( + 10, + (i) => _createTestDocumentEntity(id: 'template$i', type: DocumentType.proposalTemplate), + ); + + await dao.saveAll([...proposals, ...templates]); + + final stream = dao.watchDocuments( + type: DocumentType.proposalDocument, + limit: 5, + offset: 10, + ); + + await expectLater( + stream, + emits( + predicate>((docs) { + return docs.length == 5 && docs.every((d) => d.type == DocumentType.proposalDocument); + }), + ), + ); + }); + + test('emits updates when filtered documents change', () async { + final proposal = _createTestDocumentEntity(id: 'id1', type: DocumentType.proposalDocument); + final template = _createTestDocumentEntity(id: 'id2', type: DocumentType.proposalTemplate); + + final stream = dao.watchDocuments(type: DocumentType.proposalDocument); + + final expectation = expectLater( + stream, + emitsInOrder([ + isEmpty, + hasLength(1), + hasLength(1), + ]), + ); + + await pumpEventQueue(); + await dao.save(proposal); + await pumpEventQueue(); + await dao.save(template); + await pumpEventQueue(); + + await expectation; + }); + + test('returns all versions when latestOnly is false', () async { + final v1 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 1)), + ); + final v2 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 2)), + ); + final v3 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 3)), + ); + + await dao.saveAll([v1, v2, v3]); + + final stream = dao.watchDocuments(latestOnly: false); + + await expectLater( + stream, + emits(hasLength(3)), + ); + }); + + test('returns only latest version of each document when latestOnly is true', () async { + final doc1v1 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 1)), + ); + final doc1v2 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 2)), + ); + final doc2v1 = _createTestDocumentEntity( + id: 'id2', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 1)), + ); + + await dao.saveAll([doc1v1, doc1v2, doc2v1]); + + final stream = dao.watchDocuments(latestOnly: true); + + final result = await stream.first; + + expect(result.length, 2); + expect(result.any((d) => d.id == 'id1' && d.ver == doc1v1.doc.ver), isFalse); + expect(result.any((d) => d.id == 'id1' && d.ver == doc1v2.doc.ver), isTrue); + expect(result.any((d) => d.id == 'id2' && d.ver == doc2v1.doc.ver), isTrue); + }); + + test('combines latestOnly with type filter', () async { + final proposal1v1 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 1)), + type: DocumentType.proposalDocument, + ); + final proposal1v2 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 2)), + type: DocumentType.proposalDocument, + ); + final template1v1 = _createTestDocumentEntity( + id: 'id2', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 1)), + type: DocumentType.proposalTemplate, + ); + final template1v2 = _createTestDocumentEntity( + id: 'id2', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 2)), + type: DocumentType.proposalTemplate, + ); + + await dao.saveAll([proposal1v1, proposal1v2, template1v1, template1v2]); + + final stream = dao.watchDocuments( + type: DocumentType.proposalDocument, + latestOnly: true, + ); + + final result = await stream.first; + + expect(result.length, 1); + expect(result.first.id, 'id1'); + expect(result.first.ver, proposal1v2.doc.ver); + expect(result.first.type, DocumentType.proposalDocument); + }); + + test('combines latestOnly with limit and offset', () async { + final docs = []; + for (var i = 0; i < 10; i++) { + docs + ..add( + _createTestDocumentEntity( + id: 'id$i', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 1)), + ), + ) + ..add( + _createTestDocumentEntity( + id: 'id$i', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 2)), + ), + ); + } + + await dao.saveAll(docs); + + final stream = dao.watchDocuments( + latestOnly: true, + limit: 5, + offset: 3, + ); + + final result = await stream.first; + + expect(result.length, 5); + expect( + result.every((d) => d.createdAt.isAtSameMomentAs(DateTime.utc(2024, 1, 2))), + isTrue, + ); + }); + + test('emits updates when new version added with latestOnly', () async { + final doc1v1 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 1)), + ); + final doc1v2 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 2)), + ); + + final stream = dao.watchDocuments(latestOnly: true); + + final expectation = expectLater( + stream, + emitsInOrder([ + isEmpty, + predicate>( + (docs) => docs.length == 1 && docs.first.ver == doc1v1.doc.ver, + ), + predicate>( + (docs) => docs.length == 1 && docs.first.ver == doc1v2.doc.ver, + ), + ]), + ); + + await pumpEventQueue(); + await dao.save(doc1v1); + await pumpEventQueue(); + await dao.save(doc1v2); + await pumpEventQueue(); + + await expectation; + }); + }); }); } @@ -535,7 +925,7 @@ DocumentWithAuthorsEntity _createTestDocumentEntity({ id: id, ver: ver, content: DocumentDataContent(contentData), - createdAt: ver.tryDateTime ?? DateTime.now(), + createdAt: ver.tryDateTime ?? DateTime.timestamp(), type: type, authors: authors, categoryId: categoryId, 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 5017d64b02e8..3f9b0dab6eb3 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 @@ -4187,6 +4187,771 @@ void main() { }); }); }); + + group('getProposalTemplatesTotalTask', () { + 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); + + final nodeId = DocumentNodeId.fromString('summary.budget.requestedFunds'); + + test('returns empty map when categories list is empty', () async { + const filters = CampaignFilters(categoriesIds: []); + + final result = await dao.getProposalTemplatesTotalTask( + filters: filters, + nodeId: nodeId, + ); + + expect(result, {}); + }); + + test('returns empty map when no final proposals exist', () async { + final draftProposal = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([draftProposal]); + + const filters = CampaignFilters(categoriesIds: ['cat-1']); + + final result = await dao.getProposalTemplatesTotalTask( + filters: filters, + nodeId: nodeId, + ); + + expect(result, {}); + }); + + test('aggregates budget from single template with final proposals', () async { + final proposal1Ver = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final proposal2Ver = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 25000}, + }, + }, + ); + + final finalAction1 = _createTestDocumentEntity( + id: 'action-1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final finalAction2 = _createTestDocumentEntity( + id: 'action-2', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); + + const filters = CampaignFilters(categoriesIds: ['cat-1']); + + final result = await dao.getProposalTemplatesTotalTask( + filters: filters, + nodeId: nodeId, + ); + + const templateRef = SignedDocumentRef( + id: 'template-1', + version: 'template-1-ver', + ); + final templateResult = result[templateRef]; + + expect(result.length, 1); + expect(templateResult, isNotNull); + expect(templateResult!.totalAsk, 35000); + expect(templateResult.finalProposalsCount, 2); + }); + + test('groups by different template versions separately', () async { + final proposal1Ver = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-v1', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final proposal2Ver = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-v2', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 20000}, + }, + }, + ); + + final finalAction1 = _createTestDocumentEntity( + id: 'action-1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final finalAction2 = _createTestDocumentEntity( + id: 'action-2', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); + + const filters = CampaignFilters(categoriesIds: ['cat-1']); + + final result = await dao.getProposalTemplatesTotalTask( + filters: filters, + nodeId: nodeId, + ); + + const templateRef1 = SignedDocumentRef( + id: 'template-1', + version: 'template-1-v1', + ); + const templateRef2 = SignedDocumentRef( + id: 'template-1', + version: 'template-1-v2', + ); + + expect(result.length, 2); + expect(result[templateRef1]!.totalAsk, 10000); + expect(result[templateRef1]!.finalProposalsCount, 1); + expect(result[templateRef2]!.totalAsk, 20000); + expect(result[templateRef2]!.finalProposalsCount, 1); + }); + + test('groups by different templates separately', () async { + final proposal1Ver = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final proposal2Ver = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + categoryId: 'cat-1', + templateId: 'template-2', + templateVer: 'template-2-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 30000}, + }, + }, + ); + + final finalAction1 = _createTestDocumentEntity( + id: 'action-1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final finalAction2 = _createTestDocumentEntity( + id: 'action-2', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); + + const filters = CampaignFilters(categoriesIds: ['cat-1']); + + final result = await dao.getProposalTemplatesTotalTask( + filters: filters, + nodeId: nodeId, + ); + + const templateRef1 = SignedDocumentRef( + id: 'template-1', + version: 'template-1-ver', + ); + const templateRef2 = SignedDocumentRef( + id: 'template-2', + version: 'template-2-ver', + ); + + expect(result.length, 2); + expect(result[templateRef1]!.totalAsk, 10000); + expect(result[templateRef1]!.finalProposalsCount, 1); + expect(result[templateRef2]!.totalAsk, 30000); + expect(result[templateRef2]!.finalProposalsCount, 1); + }); + + test('treats non-integer budget values as 0', () async { + final proposal1Ver = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 'not-a-number'}, + }, + }, + ); + + final proposal2Ver = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 15000}, + }, + }, + ); + + final finalAction1 = _createTestDocumentEntity( + id: 'action-1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final finalAction2 = _createTestDocumentEntity( + id: 'action-2', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); + + const filters = CampaignFilters(categoriesIds: ['cat-1']); + + final result = await dao.getProposalTemplatesTotalTask( + filters: filters, + nodeId: nodeId, + ); + + const templateRef = SignedDocumentRef( + id: 'template-1', + version: 'template-1-ver', + ); + + expect(result[templateRef]!.totalAsk, 15000); + expect(result[templateRef]!.finalProposalsCount, 2); + }); + + test('respects category filter', () async { + final proposal1Ver = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final proposal2Ver = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + categoryId: 'cat-2', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 30000}, + }, + }, + ); + + final finalAction1 = _createTestDocumentEntity( + id: 'action-1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final finalAction2 = _createTestDocumentEntity( + id: 'action-2', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); + + const filters = CampaignFilters(categoriesIds: ['cat-1']); + + final result = await dao.getProposalTemplatesTotalTask( + filters: filters, + nodeId: nodeId, + ); + + const templateRef = SignedDocumentRef( + id: 'template-1', + version: 'template-1-ver', + ); + + expect(result.length, 1); + expect(result[templateRef]!.totalAsk, 10000); + expect(result[templateRef]!.finalProposalsCount, 1); + }); + + test('handles multiple categories in filter', () async { + final proposal1Ver = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final proposal2Ver = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + categoryId: 'cat-2', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 20000}, + }, + }, + ); + + final proposal3Ver = _buildUuidV7At(middle); + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: proposal3Ver, + categoryId: 'cat-3', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 30000}, + }, + }, + ); + + final finalAction1 = _createTestDocumentEntity( + id: 'action-1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final finalAction2 = _createTestDocumentEntity( + id: 'action-2', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final finalAction3 = _createTestDocumentEntity( + id: 'action-3', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p3', + refVer: proposal3Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([ + proposal1, + proposal2, + proposal3, + finalAction1, + finalAction2, + finalAction3, + ]); + + const filters = CampaignFilters(categoriesIds: ['cat-1', 'cat-2']); + + final result = await dao.getProposalTemplatesTotalTask( + filters: filters, + nodeId: nodeId, + ); + + const templateRef = SignedDocumentRef( + id: 'template-1', + version: 'template-1-ver', + ); + + expect(result.length, 1); + expect(result[templateRef]!.totalAsk, 30000); + expect(result[templateRef]!.finalProposalsCount, 2); + }); + + test('uses correct version when final action points to specific version', () async { + final proposalV1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(earliest), + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final proposalV2Ver = _buildUuidV7At(middle); + final proposalV2 = _createTestDocumentEntity( + id: 'p1', + ver: proposalV2Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 25000}, + }, + }, + ); + + final proposalV3 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 50000}, + }, + }, + ); + + final finalAction = _createTestDocumentEntity( + id: 'action-final', + ver: _buildUuidV7At(latest.add(const Duration(hours: 1))), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposalV2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposalV1, proposalV2, proposalV3, finalAction]); + + const filters = CampaignFilters(categoriesIds: ['cat-1']); + + final result = await dao.getProposalTemplatesTotalTask( + filters: filters, + nodeId: nodeId, + ); + + const templateRef = SignedDocumentRef( + id: 'template-1', + version: 'template-1-ver', + ); + + expect(result[templateRef]!.totalAsk, 25000); + expect(result[templateRef]!.finalProposalsCount, 1); + }); + + test('excludes final actions without valid ref_ver', () async { + final proposalV1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(earliest), + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final proposalV2Ver = _buildUuidV7At(latest); + final proposalV2 = _createTestDocumentEntity( + id: 'p1', + ver: proposalV2Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 30000}, + }, + }, + ); + + final finalActionWithoutRefVer = _createTestDocumentEntity( + id: 'action-final', + ver: _buildUuidV7At(latest.add(const Duration(hours: 1))), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: null, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposalV1, proposalV2, finalActionWithoutRefVer]); + + const filters = CampaignFilters(categoriesIds: ['cat-1']); + + final result = await dao.getProposalTemplatesTotalTask( + filters: filters, + nodeId: nodeId, + ); + + expect(result, {}); + }); + + test('extracts value from custom nodeId path', () async { + final customNodeId = DocumentNodeId.fromString('custom.path.value'); + + final proposal1Ver = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'custom': { + 'path': {'value': 5000}, + }, + }, + ); + + final proposal2Ver = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'custom': { + 'path': {'value': 7500}, + }, + }, + ); + + final finalAction1 = _createTestDocumentEntity( + id: 'action-1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final finalAction2 = _createTestDocumentEntity( + id: 'action-2', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); + + const filters = CampaignFilters(categoriesIds: ['cat-1']); + + final result = await dao.getProposalTemplatesTotalTask( + filters: filters, + nodeId: customNodeId, + ); + + const templateRef = SignedDocumentRef( + id: 'template-1', + version: 'template-1-ver', + ); + + expect(result[templateRef]!.totalAsk, 12500); + expect(result[templateRef]!.finalProposalsCount, 2); + }); + }); + + group('watchProposalTemplatesTotalTask', () { + // ignore: unused_local_variable + 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); + + final nodeId = DocumentNodeId.fromString('summary.budget.requestedFunds'); + + test('returns empty map when categories list is empty', () async { + const filters = CampaignFilters(categoriesIds: []); + + final stream = dao.watchProposalTemplatesTotalTask( + filters: filters, + nodeId: nodeId, + ); + + await expectLater( + stream, + emits({}), + ); + }); + + test('stream emits updated values when data changes', () async { + const templateRef = SignedDocumentRef( + id: 'template-1', + version: 'template-1-ver', + ); + + final proposal1Ver = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + categoryId: 'cat-1', + templateId: templateRef.id, + templateVer: templateRef.version, + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final finalAction1 = _createTestDocumentEntity( + id: 'action-1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, finalAction1]); + + final emissions = >[]; + const filters = CampaignFilters(categoriesIds: ['cat-1']); + + final subscription = dao + .watchProposalTemplatesTotalTask(filters: filters, nodeId: nodeId) + .listen(emissions.add); + + await pumpEventQueue(); + expect(emissions.length, 1); + expect(emissions[0][templateRef]!.totalAsk, 10000); + + final proposal2Ver = _buildUuidV7At(middle.add(const Duration(hours: 1))); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + categoryId: 'cat-1', + templateId: templateRef.id, + templateVer: templateRef.version, + contentData: { + 'summary': { + 'budget': {'requestedFunds': 20000}, + }, + }, + ); + + final finalAction2 = _createTestDocumentEntity( + id: 'action-2', + ver: _buildUuidV7At(latest.add(const Duration(hours: 1))), + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal2, finalAction2]); + await pumpEventQueue(); + + expect(emissions.length, 2); + expect(emissions[1][templateRef]!.totalAsk, 30000); + + await subscription.cancel(); + }); + }); }); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/logging_db_interceptor.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/logging_db_interceptor.dart new file mode 100644 index 000000000000..8c069354ca1e --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/logging_db_interceptor.dart @@ -0,0 +1,214 @@ +import 'dart:async'; + +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter/cupertino.dart'; + +const _clauseKeywords = [ + 'SELECT', + 'FROM', + 'INNER JOIN', + 'LEFT JOIN', + 'RIGHT JOIN', + 'JOIN', + 'ON', + 'WHERE', + 'GROUP BY', + 'ORDER BY', + 'HAVING', + 'LIMIT', + 'OFFSET', +]; +const _indent = ' '; + +/// Interceptor that logs all database operations. +final class LoggingDbInterceptor extends QueryInterceptor { + LoggingDbInterceptor(); + + @override + Future commitTransaction(TransactionExecutor inner) { + return _run( + () => 'commit', + () => super.commitTransaction(inner), + ); + } + + @override + Future rollbackTransaction(TransactionExecutor inner) { + return _run( + () => 'rollback', + () => super.rollbackTransaction(inner), + ); + } + + @override + Future runBatched( + QueryExecutor executor, + BatchedStatements statements, + ) { + return _run( + () => 'batch with ${_prettyBatch(statements)}', + () => super.runBatched(executor, statements), + ); + } + + @override + Future runCustom( + QueryExecutor executor, + String statement, + List args, + ) { + return _run( + () => _prettyFormat(statement, args), + () => super.runCustom(executor, statement, args), + ); + } + + @override + Future runDelete( + QueryExecutor executor, + String statement, + List args, + ) { + return _run( + () => _prettyFormat(statement, args), + () => super.runDelete(executor, statement, args), + ); + } + + @override + Future runInsert( + QueryExecutor executor, + String statement, + List args, + ) { + return _run( + () => _prettyFormat(statement, args), + () => super.runInsert(executor, statement, args), + ); + } + + @override + Future>> runSelect( + QueryExecutor executor, + String statement, + List args, + ) { + return _run( + () => _prettyFormat(statement, args), + () => super.runSelect(executor, statement, args), + ); + } + + @override + Future runUpdate( + QueryExecutor executor, + String statement, + List args, + ) { + return _run( + () => _prettyFormat(statement, args), + () => super.runUpdate(executor, statement, args), + ); + } + + void _log( + String message, [ + Object? error, + StackTrace? stack, + ]) { + debugPrint(message); + if (error != null) { + debugPrint('$error'); + } + if (stack != null) { + debugPrintStack(stackTrace: stack); + } + } + + String _prettyBatch(BatchedStatements statements) { + return statements.statements + .mapIndexed( + (index, statement) { + final args = statements.arguments + .firstWhereOrNull((args) => args.statementIndex == index) + ?.arguments; + + return _prettyFormat(statement, args ?? []); + }, + ) + .join(', '); + } + + String _prettyFormat(String statement, List args) { + var formatted = statement + // Insert args + .replaceAllMappedIndexed( + '?', + (match, index) { + final arg = args.elementAtOrNull(index); + final formattedArg = arg is Uint8List ? '*bytes*' : arg; + + return formattedArg.toString(); + }, + ) + // Normalize spacing + .replaceAll(RegExp(r'\s+'), ' '); + + for (final keyword in _clauseKeywords) { + final pattern = RegExp('\\b$keyword\\b', caseSensitive: false); + formatted = formatted.replaceAllMapped( + pattern, + (match) => '\n${match.group(0)}', + ); + } + + // Line breaks for AND/OR within WHERE and ON + formatted = formatted.replaceAllMapped( + RegExp(r'\b(AND|OR)\b', caseSensitive: false), + (match) => '\n ${match.group(0)}', + ); + + // New lines after commas outside parentheses (e.g., SELECT, GROUP BY) + formatted = formatted.replaceAllMapped( + RegExp(r',(?![^()]*\))'), + (match) => ',\n ', + ); + + // Indentation + final lines = formatted.split('\n'); + final buffer = StringBuffer(); + + var indentLevel = 0; + + for (var line in lines) { + line = line.trim(); + + // Adjust indent level based on parentheses + final openParens = '('.allMatches(line).length; + final closeParens = ')'.allMatches(line).length; + + if (closeParens > openParens) { + indentLevel = (indentLevel - (closeParens - openParens)).clamp(0, indentLevel); + } + + buffer.writeln('${_indent * indentLevel}$line'); + + if (openParens > closeParens) { + indentLevel += (openParens - closeParens); + } + } + + return buffer.toString().trim(); + } + + Future _run( + String Function() description, + FutureOr Function() operation, + ) async { + _log('Running ${description()}'); + + return await operation(); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart index 89758c43d837..363c8187c86a 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart @@ -1,7 +1,12 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; import 'package:catalyst_voices_services/src/campaign/active_campaign_observer.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:rxdart/rxdart.dart'; + +final logger = Logger('CampaignService'); Campaign? _mockedActiveCampaign; @@ -13,6 +18,11 @@ set mockedActiveCampaign(Campaign? campaign) { _mockedActiveCampaign = campaign; } +typedef _ProposalTemplateCategoryAndMoneyFormat = ({ + SignedDocumentRef? category, + MoneyFormat? moneyFormat, +}); + /// CampaignService provides campaign-related functionality. /// /// [CampaignRepository] is used to get the campaign data. @@ -35,8 +45,6 @@ abstract interface class CampaignService { Future getCampaignPhaseTimeline(CampaignPhaseType stage); - Future getCampaignTotalAsk({required CampaignFilters filters}); - Future getCategory(SignedDocumentRef ref); Future getCategoryTotalAsk({required SignedDocumentRef ref}); @@ -105,12 +113,6 @@ final class CampaignServiceImpl implements CampaignService { return timelineStage; } - @override - Future getCampaignTotalAsk({required CampaignFilters filters}) { - // TODO(damian-molinski): implement it. - return Future(() => const CampaignTotalAsk(categoriesAsks: {})); - } - @override Future getCategory(SignedDocumentRef ref) async { final category = await _campaignRepository.getCategory(ref); @@ -131,19 +133,85 @@ final class CampaignServiceImpl implements CampaignService { @override Future getCategoryTotalAsk({required SignedDocumentRef ref}) { - // TODO(damian-molinski): implement it. - return Future(() => CampaignCategoryTotalAsk.zero(ref)); + return watchCategoryTotalAsk(ref: ref).first; } @override Stream watchCampaignTotalAsk({required CampaignFilters filters}) { - // TODO(damian-molinski): implement it. - return Stream.value(const CampaignTotalAsk(categoriesAsks: {})); + return _proposalRepository + .watchProposalTemplates(filters: filters) + .map((templates) => templates.map((template) => template.toMapEntry())) + .map(Map.fromEntries) + .switchMap((templatesMoneyFormat) { + // This could come from templates + final nodeId = ProposalDocument.requestedFundsNodeId; + + return _campaignRepository + .watchProposalTemplatesTotalTask(filters: filters, nodeId: nodeId) + .map((totalAsk) => _calculateCampaignTotalAsk(templatesMoneyFormat, totalAsk)); + }); } @override Stream watchCategoryTotalAsk({required SignedDocumentRef ref}) { - // TODO(damian-molinski): implement it. - return Stream.value(CampaignCategoryTotalAsk.zero(ref)); + return watchCampaignTotalAsk(filters: CampaignFilters(categoriesIds: [ref.id])).map( + (campaignTotalAsk) { + return campaignTotalAsk.categoriesAsks.entries + .firstWhereOrNull((entry) => entry.key.id == ref.id) + ?.value ?? + CampaignCategoryTotalAsk.zero(ref); + }, + ); + } + + CampaignTotalAsk _calculateCampaignTotalAsk( + Map templatesMoneyFormat, + Map totalAsk, + ) { + final categoriesAsks = {}; + + for (final entry in totalAsk.entries) { + final templateRef = entry.key; + final categoryRef = templatesMoneyFormat[templateRef]?.category; + final moneyFormat = templatesMoneyFormat[templateRef]?.moneyFormat; + + if (categoryRef == null || moneyFormat == null) { + if (categoryRef == null) logger.info('Template[$templateRef] do not have category'); + if (moneyFormat == null) logger.info('Template[$templateRef] do not have moneyFormat'); + continue; + } + + final proposalTotalAsk = entry.value; + final finalProposalsCount = proposalTotalAsk.finalProposalsCount; + final money = Money.fromUnits( + currency: moneyFormat.currency, + amount: BigInt.from(proposalTotalAsk.totalAsk), + moneyUnits: moneyFormat.moneyUnits, + ); + + final ask = CampaignCategoryTotalAsk( + ref: categoryRef, + finalProposalsCount: finalProposalsCount, + money: [money], + ); + + categoriesAsks.update(categoryRef, (value) => value + ask, ifAbsent: () => ask); + } + + return CampaignTotalAsk(categoriesAsks: Map.unmodifiable(categoriesAsks)); + } +} + +extension on ProposalTemplate { + MapEntry toMapEntry() { + final ref = metadata.selfRef; + final category = metadata.categoryId; + + final currencySchema = requestedFunds; + final moneyFormat = currencySchema != null + ? MoneyFormat(currency: currencySchema.currency, moneyUnits: currencySchema.moneyUnits) + : null; + + return MapEntry(ref, (category: category, moneyFormat: moneyFormat)); } } From 5f4d8edcd56aaf0607001664ad955233d7b8eb29 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Tue, 18 Nov 2025 10:43:58 +0100 Subject: [PATCH 13/30] finish integration of total_ask --- .../lib/src/discovery/discovery_cubit.dart | 3 +- .../new_proposal/new_proposal_cubit.dart | 3 +- .../lib/src/campaign/campaign_total_ask.dart | 8 + .../lib/src/catalyst_voices_models.dart | 3 +- .../data/proposal_template_total_ask.dart | 17 --- .../proposal/data/proposals_total_ask.dart | 43 ++++++ .../proposals_total_ask_filters.dart | 24 +++ .../lib/src/campaign/campaign_repository.dart | 23 ++- .../src/database/dao/proposals_v2_dao.dart | 88 +++++++---- .../database_documents_data_source.dart | 28 ++-- .../proposal_document_data_local_source.dart | 13 +- .../database/dao/proposals_v2_dao_test.dart | 140 ++++++++++-------- .../lib/src/campaign/campaign_service.dart | 30 ++-- 13 files changed, 286 insertions(+), 137 deletions(-) delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_template_total_ask.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposals_total_ask.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_total_ask_filters.dart 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 16736b976f76..50eff2b2bba0 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 @@ -205,8 +205,9 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { } void _watchCampaignTotalAsk(Campaign campaign) { + final filters = ProposalsTotalAskFilters(campaign: CampaignFilters.from(campaign)); _activeCampaignTotalAskSub = _campaignService - .watchCampaignTotalAsk(filters: CampaignFilters.from(campaign)) + .watchCampaignTotalAsk(filters: filters) .distinct() .listen(_handleCampaignTotalAskChange); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart index 6b38b938420c..21d1ea02d390 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart @@ -236,8 +236,9 @@ class NewProposalCubit extends Cubit } void _watchCampaignTotalAsk(Campaign campaign) { + final filters = ProposalsTotalAskFilters(campaign: CampaignFilters.from(campaign)); _activeCampaignTotalAskSub = _campaignService - .watchCampaignTotalAsk(filters: CampaignFilters.from(campaign)) + .watchCampaignTotalAsk(filters: filters) .distinct() .listen(_handleCampaignTotalAskChange); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_total_ask.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_total_ask.dart index e5f8a7357733..611acf482c85 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_total_ask.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_total_ask.dart @@ -16,4 +16,12 @@ final class CampaignTotalAsk extends Equatable { final categoriesMoney = categoriesAsks.values.map((e) => e.money).flattened.toList(); return MultiCurrencyAmount.list(categoriesMoney); } + + CampaignCategoryTotalAsk? category(SignedDocumentRef ref) { + return categoriesAsks.entries.firstWhereOrNull((entry) => entry.key.id == ref.id)?.value; + } + + CampaignCategoryTotalAsk categoryOrZero(SignedDocumentRef ref) { + return category(ref) ?? CampaignCategoryTotalAsk.zero(ref); + } } 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 3ae910bf0b03..33d5ae419fbb 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 @@ -84,7 +84,7 @@ export 'permissions/exceptions/permission_exceptions.dart'; export 'proposal/core_proposal.dart'; export 'proposal/data/joined_proposal_brief_data.dart'; export 'proposal/data/proposal_brief_data.dart'; -export 'proposal/data/proposal_template_total_ask.dart'; +export 'proposal/data/proposals_total_ask.dart'; export 'proposal/detail_proposal.dart'; export 'proposal/exception/proposal_limit_reached_exception.dart'; export 'proposal/proposal.dart'; @@ -101,6 +101,7 @@ export 'proposals/proposals_count_filters.dart'; export 'proposals/proposals_filters.dart'; export 'proposals/proposals_filters_v2.dart'; export 'proposals/proposals_order.dart'; +export 'proposals/proposals_total_ask_filters.dart'; export 'registration/account_submit_data.dart'; export 'registration/registration.dart'; export 'share/share_channel.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_template_total_ask.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_template_total_ask.dart deleted file mode 100644 index e1775095d271..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_template_total_ask.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:equatable/equatable.dart'; - -final class ProposalTemplateTotalAsk extends Equatable { - final int totalAsk; - final int finalProposalsCount; - - const ProposalTemplateTotalAsk({ - required this.totalAsk, - required this.finalProposalsCount, - }); - - @override - List get props => [ - totalAsk, - finalProposalsCount, - ]; -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposals_total_ask.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposals_total_ask.dart new file mode 100644 index 000000000000..917e90c8372a --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposals_total_ask.dart @@ -0,0 +1,43 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +/// A container for the total funding asked by proposals, organized by templates. +/// +/// The [data] map uses a [DocumentRef] (representing a template) as the key +/// and a [ProposalsTotalAskPerTemplate] object as the value. +final class ProposalsTotalAsk extends Equatable { + /// A map where each key is a reference to a template document and + /// the value contains the aggregated ask data for that template's proposals. + final Map data; + + const ProposalsTotalAsk(this.data); + + @override + List get props => [ + data, + ]; +} + +/// Represents the aggregated funding information for proposals +/// within a single template. +final class ProposalsTotalAskPerTemplate extends Equatable { + /// The sum of the `amountAsked` for all final proposals in a template. + final int totalAsk; + + /// The total number of final proposals counted in a template. + final int finalProposalsCount; + + /// Creates an instance of [ProposalsTotalAskPerTemplate]. + /// + /// Both [totalAsk] and [finalProposalsCount] are required. + const ProposalsTotalAskPerTemplate({ + required this.totalAsk, + required this.finalProposalsCount, + }); + + @override + List get props => [ + totalAsk, + finalProposalsCount, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_total_ask_filters.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_total_ask_filters.dart new file mode 100644 index 000000000000..2f6e50daff20 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_total_ask_filters.dart @@ -0,0 +1,24 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +/// A class that encapsulates the filters used to calculate the total +/// ask for a set of proposals. +final class ProposalsTotalAskFilters extends Equatable { + /// The general campaign filters to apply. + final CampaignFilters? campaign; + + /// The specific category ID to filter by. + final String? categoryId; + + /// Creates an instance of [ProposalsTotalAskFilters]. + const ProposalsTotalAskFilters({ + this.campaign, + this.categoryId, + }); + + @override + List get props => [ + campaign, + categoryId, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/campaign/campaign_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/campaign/campaign_repository.dart index 43f688bfc1b6..81ee49848a12 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/campaign/campaign_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/campaign/campaign_repository.dart @@ -12,9 +12,14 @@ abstract interface class CampaignRepository { Future getCategory(SignedDocumentRef ref); - Stream> watchProposalTemplatesTotalTask({ - required CampaignFilters filters, + Future getProposalsTotalTask({ required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }); + + Stream watchProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, }); } @@ -44,10 +49,18 @@ final class CampaignRepositoryImpl implements CampaignRepository { } @override - Stream> watchProposalTemplatesTotalTask({ - required CampaignFilters filters, + Future getProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }) { + return _source.getProposalsTotalTask(nodeId: nodeId, filters: filters); + } + + @override + Stream watchProposalsTotalTask({ required NodeId nodeId, + required ProposalsTotalAskFilters filters, }) { - return _source.watchProposalTemplatesTotalTask(filters: filters, nodeId: nodeId); + return _source.watchProposalsTotalTask(nodeId: nodeId, filters: filters); } } 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 10cd5a48ad1b..5da89ee5609f 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 @@ -118,20 +118,18 @@ class DriftProposalsV2Dao extends DatabaseAccessor } @override - Future> getProposalTemplatesTotalTask({ - required CampaignFilters filters, + Future getProposalsTotalTask({ required NodeId nodeId, + required ProposalsTotalAskFilters filters, }) async { - if (filters.categoriesIds.isEmpty) { - return {}; + if (_totalAskShouldReturnEarlyFor(filters: filters)) { + return const ProposalsTotalAsk({}); } - final entries = await _queryProposalTemplatesTotalTask( + return _queryProposalsTotalTask( filters: filters, nodeId: nodeId, - ).get(); - - return Map.fromEntries(entries); + ).get().then(Map.fromEntries).then(ProposalsTotalAsk.new); } @override @@ -200,18 +198,18 @@ class DriftProposalsV2Dao extends DatabaseAccessor } @override - Stream> watchProposalTemplatesTotalTask({ - required CampaignFilters filters, + Stream watchProposalsTotalTask({ required NodeId nodeId, + required ProposalsTotalAskFilters filters, }) { - if (filters.categoriesIds.isEmpty) { - return Stream.value({}); + if (_totalAskShouldReturnEarlyFor(filters: filters)) { + return Stream.value(const ProposalsTotalAsk({})); } - return _queryProposalTemplatesTotalTask( - filters: filters, + return _queryProposalsTotalTask( nodeId: nodeId, - ).watch().map(Map.fromEntries); + filters: filters, + ).watch().map(Map.fromEntries).map(ProposalsTotalAsk.new); } @override @@ -313,6 +311,22 @@ class DriftProposalsV2Dao extends DatabaseAccessor return clauses; } + List _buildFilterTotalAskClauses(ProposalsTotalAskFilters filters) { + final clauses = []; + + 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)'); + } + + return clauses; + } + /// Builds the ORDER BY clause based on the provided ordering. /// /// Supports multiple ordering strategies: @@ -472,13 +486,12 @@ class DriftProposalsV2Dao extends DatabaseAccessor return input.replaceAll("'", "''"); } - Selectable> _queryProposalTemplatesTotalTask({ - required CampaignFilters filters, + Selectable> _queryProposalsTotalTask({ required NodeId nodeId, + required ProposalsTotalAskFilters filters, }) { - final escapedCategories = filters.categoriesIds - .map((id) => "'${_escapeSqlString(id)}'") - .join(', '); + final filterClauses = _buildFilterTotalAskClauses(filters); + final filterWhereClause = filterClauses.isEmpty ? '' : 'AND ${filterClauses.join(' AND ')}'; final query = ''' @@ -514,7 +527,7 @@ class DriftProposalsV2Dao extends DatabaseAccessor FROM documents_v2 p INNER JOIN effective_final_proposals efp ON p.id = efp.id AND p.ver = efp.ver WHERE p.type = ? - AND p.category_id IN ($escapedCategories) + $filterWhereClause AND p.template_id IS NOT NULL AND p.template_ver IS NOT NULL GROUP BY p.template_id, p.template_ver @@ -539,7 +552,7 @@ class DriftProposalsV2Dao extends DatabaseAccessor version: templateVer, ); - final value = ProposalTemplateTotalAsk( + final value = ProposalsTotalAskPerTemplate( totalAsk: totalAsk, finalProposalsCount: finalProposalsCount, ); @@ -778,6 +791,29 @@ class DriftProposalsV2Dao extends DatabaseAccessor return false; } + + bool _totalAskShouldReturnEarlyFor({ + required ProposalsTotalAskFilters filters, + }) { + 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. @@ -831,9 +867,9 @@ abstract interface class ProposalsV2Dao { ProposalsFiltersV2 filters, }); - Future> getProposalTemplatesTotalTask({ - required CampaignFilters filters, + Future getProposalsTotalTask({ required NodeId nodeId, + required ProposalsTotalAskFilters filters, }); /// Counts the total number of visible proposals that match the given filters. @@ -887,9 +923,9 @@ abstract interface class ProposalsV2Dao { ProposalsFiltersV2 filters, }); - Stream> watchProposalTemplatesTotalTask({ - required CampaignFilters filters, + Stream watchProposalsTotalTask({ required NodeId nodeId, + required ProposalsTotalAskFilters filters, }); /// Watches for changes and emits the total count of visible proposals. 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 a4f471f215ef..3fd8d9597fc9 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 @@ -93,6 +93,14 @@ final class DatabaseDocumentsDataSource .then((page) => page.map((e) => e.toModel())); } + @override + Future getProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }) { + return _database.proposalsV2Dao.getProposalsTotalTask(filters: filters, nodeId: nodeId); + } + @override Future getRefCount({ required DocumentRef ref, @@ -223,6 +231,16 @@ final class DatabaseDocumentsDataSource .map((page) => page.map((e) => e.toModel())); } + @override + Stream watchProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }) { + return _database.proposalsV2Dao + .watchProposalsTotalTask(filters: filters, nodeId: nodeId) + .distinct(); + } + @override Stream> watchProposalTemplates({ required CampaignFilters filters, @@ -233,16 +251,6 @@ final class DatabaseDocumentsDataSource .map((event) => event.map((e) => e.toModel()).toList()); } - @override - Stream> watchProposalTemplatesTotalTask({ - required CampaignFilters filters, - required NodeId nodeId, - }) { - return _database.proposalsV2Dao - .watchProposalTemplatesTotalTask(filters: filters, nodeId: nodeId) - .distinct(); - } - @override Stream watchRefToDocumentData({ required DocumentRef refTo, 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 ca57472f9b64..2ef41dfff69c 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 @@ -20,6 +20,11 @@ abstract interface class ProposalDocumentDataLocalSource { required ProposalsOrder order, }); + Future getProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }); + Future updateProposalFavorite({ required String id, required bool isFavorite, @@ -45,12 +50,12 @@ abstract interface class ProposalDocumentDataLocalSource { required ProposalsOrder order, }); - Stream> watchProposalTemplates({ - required CampaignFilters filters, + Stream watchProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, }); - Stream> watchProposalTemplatesTotalTask({ + Stream> watchProposalTemplates({ required CampaignFilters filters, - required NodeId nodeId, }); } 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 3f9b0dab6eb3..71a07956a167 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 @@ -4188,7 +4188,7 @@ void main() { }); }); - group('getProposalTemplatesTotalTask', () { + group('getProposalsTotalTask', () { 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); @@ -4196,14 +4196,14 @@ void main() { final nodeId = DocumentNodeId.fromString('summary.budget.requestedFunds'); test('returns empty map when categories list is empty', () async { - const filters = CampaignFilters(categoriesIds: []); + const filters = ProposalsTotalAskFilters(); - final result = await dao.getProposalTemplatesTotalTask( - filters: filters, + final result = await dao.getProposalsTotalTask( nodeId: nodeId, + filters: filters, ); - expect(result, {}); + expect(result, const ProposalsTotalAsk({})); }); test('returns empty map when no final proposals exist', () async { @@ -4222,14 +4222,16 @@ void main() { await db.documentsV2Dao.saveAll([draftProposal]); - const filters = CampaignFilters(categoriesIds: ['cat-1']); + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); - final result = await dao.getProposalTemplatesTotalTask( + final result = await dao.getProposalsTotalTask( filters: filters, nodeId: nodeId, ); - expect(result, {}); + expect(result, const ProposalsTotalAsk({})); }); test('aggregates budget from single template with final proposals', () async { @@ -4281,9 +4283,11 @@ void main() { await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); - const filters = CampaignFilters(categoriesIds: ['cat-1']); + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); - final result = await dao.getProposalTemplatesTotalTask( + final result = await dao.getProposalsTotalTask( filters: filters, nodeId: nodeId, ); @@ -4292,9 +4296,9 @@ void main() { id: 'template-1', version: 'template-1-ver', ); - final templateResult = result[templateRef]; + final templateResult = result.data[templateRef]; - expect(result.length, 1); + expect(result.data.length, 1); expect(templateResult, isNotNull); expect(templateResult!.totalAsk, 35000); expect(templateResult.finalProposalsCount, 2); @@ -4349,9 +4353,11 @@ void main() { await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); - const filters = CampaignFilters(categoriesIds: ['cat-1']); + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); - final result = await dao.getProposalTemplatesTotalTask( + final result = await dao.getProposalsTotalTask( filters: filters, nodeId: nodeId, ); @@ -4365,11 +4371,11 @@ void main() { version: 'template-1-v2', ); - expect(result.length, 2); - expect(result[templateRef1]!.totalAsk, 10000); - expect(result[templateRef1]!.finalProposalsCount, 1); - expect(result[templateRef2]!.totalAsk, 20000); - expect(result[templateRef2]!.finalProposalsCount, 1); + expect(result.data.length, 2); + expect(result.data[templateRef1]!.totalAsk, 10000); + expect(result.data[templateRef1]!.finalProposalsCount, 1); + expect(result.data[templateRef2]!.totalAsk, 20000); + expect(result.data[templateRef2]!.finalProposalsCount, 1); }); test('groups by different templates separately', () async { @@ -4421,9 +4427,11 @@ void main() { await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); - const filters = CampaignFilters(categoriesIds: ['cat-1']); + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); - final result = await dao.getProposalTemplatesTotalTask( + final result = await dao.getProposalsTotalTask( filters: filters, nodeId: nodeId, ); @@ -4437,11 +4445,11 @@ void main() { version: 'template-2-ver', ); - expect(result.length, 2); - expect(result[templateRef1]!.totalAsk, 10000); - expect(result[templateRef1]!.finalProposalsCount, 1); - expect(result[templateRef2]!.totalAsk, 30000); - expect(result[templateRef2]!.finalProposalsCount, 1); + expect(result.data.length, 2); + expect(result.data[templateRef1]!.totalAsk, 10000); + expect(result.data[templateRef1]!.finalProposalsCount, 1); + expect(result.data[templateRef2]!.totalAsk, 30000); + expect(result.data[templateRef2]!.finalProposalsCount, 1); }); test('treats non-integer budget values as 0', () async { @@ -4493,9 +4501,11 @@ void main() { await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); - const filters = CampaignFilters(categoriesIds: ['cat-1']); + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); - final result = await dao.getProposalTemplatesTotalTask( + final result = await dao.getProposalsTotalTask( filters: filters, nodeId: nodeId, ); @@ -4505,8 +4515,8 @@ void main() { version: 'template-1-ver', ); - expect(result[templateRef]!.totalAsk, 15000); - expect(result[templateRef]!.finalProposalsCount, 2); + expect(result.data[templateRef]!.totalAsk, 15000); + expect(result.data[templateRef]!.finalProposalsCount, 2); }); test('respects category filter', () async { @@ -4558,9 +4568,11 @@ void main() { await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); - const filters = CampaignFilters(categoriesIds: ['cat-1']); + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); - final result = await dao.getProposalTemplatesTotalTask( + final result = await dao.getProposalsTotalTask( filters: filters, nodeId: nodeId, ); @@ -4570,9 +4582,9 @@ void main() { version: 'template-1-ver', ); - expect(result.length, 1); - expect(result[templateRef]!.totalAsk, 10000); - expect(result[templateRef]!.finalProposalsCount, 1); + expect(result.data.length, 1); + expect(result.data[templateRef]!.totalAsk, 10000); + expect(result.data[templateRef]!.finalProposalsCount, 1); }); test('handles multiple categories in filter', () async { @@ -4654,9 +4666,11 @@ void main() { finalAction3, ]); - const filters = CampaignFilters(categoriesIds: ['cat-1', 'cat-2']); + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1', 'cat-2']), + ); - final result = await dao.getProposalTemplatesTotalTask( + final result = await dao.getProposalsTotalTask( filters: filters, nodeId: nodeId, ); @@ -4666,9 +4680,9 @@ void main() { version: 'template-1-ver', ); - expect(result.length, 1); - expect(result[templateRef]!.totalAsk, 30000); - expect(result[templateRef]!.finalProposalsCount, 2); + expect(result.data.length, 1); + expect(result.data[templateRef]!.totalAsk, 30000); + expect(result.data[templateRef]!.finalProposalsCount, 2); }); test('uses correct version when final action points to specific version', () async { @@ -4723,9 +4737,11 @@ void main() { await db.documentsV2Dao.saveAll([proposalV1, proposalV2, proposalV3, finalAction]); - const filters = CampaignFilters(categoriesIds: ['cat-1']); + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); - final result = await dao.getProposalTemplatesTotalTask( + final result = await dao.getProposalsTotalTask( filters: filters, nodeId: nodeId, ); @@ -4735,8 +4751,8 @@ void main() { version: 'template-1-ver', ); - expect(result[templateRef]!.totalAsk, 25000); - expect(result[templateRef]!.finalProposalsCount, 1); + expect(result.data[templateRef]!.totalAsk, 25000); + expect(result.data[templateRef]!.finalProposalsCount, 1); }); test('excludes final actions without valid ref_ver', () async { @@ -4778,14 +4794,16 @@ void main() { await db.documentsV2Dao.saveAll([proposalV1, proposalV2, finalActionWithoutRefVer]); - const filters = CampaignFilters(categoriesIds: ['cat-1']); + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); - final result = await dao.getProposalTemplatesTotalTask( + final result = await dao.getProposalsTotalTask( filters: filters, nodeId: nodeId, ); - expect(result, {}); + expect(result, const ProposalsTotalAsk({})); }); test('extracts value from custom nodeId path', () async { @@ -4839,9 +4857,11 @@ void main() { await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); - const filters = CampaignFilters(categoriesIds: ['cat-1']); + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); - final result = await dao.getProposalTemplatesTotalTask( + final result = await dao.getProposalsTotalTask( filters: filters, nodeId: customNodeId, ); @@ -4851,8 +4871,8 @@ void main() { version: 'template-1-ver', ); - expect(result[templateRef]!.totalAsk, 12500); - expect(result[templateRef]!.finalProposalsCount, 2); + expect(result.data[templateRef]!.totalAsk, 12500); + expect(result.data[templateRef]!.finalProposalsCount, 2); }); }); @@ -4865,16 +4885,18 @@ void main() { final nodeId = DocumentNodeId.fromString('summary.budget.requestedFunds'); test('returns empty map when categories list is empty', () async { - const filters = CampaignFilters(categoriesIds: []); + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: []), + ); - final stream = dao.watchProposalTemplatesTotalTask( + final stream = dao.watchProposalsTotalTask( filters: filters, nodeId: nodeId, ); await expectLater( stream, - emits({}), + emits(const ProposalsTotalAsk({})), ); }); @@ -4909,12 +4931,14 @@ void main() { await db.documentsV2Dao.saveAll([proposal1, finalAction1]); - final emissions = >[]; - const filters = CampaignFilters(categoriesIds: ['cat-1']); + final emissions = >[]; + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); final subscription = dao - .watchProposalTemplatesTotalTask(filters: filters, nodeId: nodeId) - .listen(emissions.add); + .watchProposalsTotalTask(filters: filters, nodeId: nodeId) + .listen((event) => emissions.add(event.data)); await pumpEventQueue(); expect(emissions.length, 1); diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart index 363c8187c86a..742147c57dd6 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart @@ -2,7 +2,6 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; import 'package:catalyst_voices_services/src/campaign/active_campaign_observer.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:rxdart/rxdart.dart'; @@ -49,7 +48,7 @@ abstract interface class CampaignService { Future getCategoryTotalAsk({required SignedDocumentRef ref}); - Stream watchCampaignTotalAsk({required CampaignFilters filters}); + Stream watchCampaignTotalAsk({required ProposalsTotalAskFilters filters}); Stream watchCategoryTotalAsk({required SignedDocumentRef ref}); } @@ -137,9 +136,9 @@ final class CampaignServiceImpl implements CampaignService { } @override - Stream watchCampaignTotalAsk({required CampaignFilters filters}) { + Stream watchCampaignTotalAsk({required ProposalsTotalAskFilters filters}) { return _proposalRepository - .watchProposalTemplates(filters: filters) + .watchProposalTemplates(filters: filters.campaign ?? CampaignFilters.active()) .map((templates) => templates.map((template) => template.toMapEntry())) .map(Map.fromEntries) .switchMap((templatesMoneyFormat) { @@ -147,30 +146,33 @@ final class CampaignServiceImpl implements CampaignService { final nodeId = ProposalDocument.requestedFundsNodeId; return _campaignRepository - .watchProposalTemplatesTotalTask(filters: filters, nodeId: nodeId) + .watchProposalsTotalTask(nodeId: nodeId, filters: filters) .map((totalAsk) => _calculateCampaignTotalAsk(templatesMoneyFormat, totalAsk)); }); } @override Stream watchCategoryTotalAsk({required SignedDocumentRef ref}) { - return watchCampaignTotalAsk(filters: CampaignFilters(categoriesIds: [ref.id])).map( - (campaignTotalAsk) { - return campaignTotalAsk.categoriesAsks.entries - .firstWhereOrNull((entry) => entry.key.id == ref.id) - ?.value ?? - CampaignCategoryTotalAsk.zero(ref); - }, + final activeCampaign = _activeCampaignObserver.campaign; + final campaignFilters = activeCampaign != null ? CampaignFilters.from(activeCampaign) : null; + + final filters = ProposalsTotalAskFilters( + categoryId: ref.id, + campaign: campaignFilters, ); + + return watchCampaignTotalAsk( + filters: filters, + ).map((campaignTotalAsk) => campaignTotalAsk.categoryOrZero(ref)); } CampaignTotalAsk _calculateCampaignTotalAsk( Map templatesMoneyFormat, - Map totalAsk, + ProposalsTotalAsk totalAsk, ) { final categoriesAsks = {}; - for (final entry in totalAsk.entries) { + for (final entry in totalAsk.data.entries) { final templateRef = entry.key; final categoryRef = templatesMoneyFormat[templateRef]?.category; final moneyFormat = templatesMoneyFormat[templateRef]?.moneyFormat; From 69946f89fbcabf3badc83d93317b9a10b4d9e960 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Tue, 18 Nov 2025 11:42:16 +0100 Subject: [PATCH 14/30] close --- ...reate_new_proposal_category_selection.dart | 28 ++++++-------- .../proposals/create_new_proposal_dialog.dart | 17 ++------- .../new_proposal/new_proposal_cubit.dart | 21 +++++++--- .../new_proposal/new_proposal_state.dart | 38 ++++++++++++++++--- 4 files changed, 63 insertions(+), 41 deletions(-) diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_category_selection.dart b/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_category_selection.dart index 1e546f5168a5..cc0174a7d479 100644 --- a/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_category_selection.dart +++ b/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_category_selection.dart @@ -1,21 +1,18 @@ import 'package:catalyst_voices/common/ext/build_context_ext.dart'; import 'package:catalyst_voices/pages/category/category_compact_detail_view.dart'; import 'package:catalyst_voices/widgets/widgets.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:collection/collection.dart'; import 'package:flutter/material.dart'; class CreateNewProposalCategorySelection extends StatefulWidget { - final List categories; - final SignedDocumentRef? selectedCategory; + final NewProposalStateCategories categories; final ValueChanged onCategorySelected; const CreateNewProposalCategorySelection({ super.key, required this.categories, - this.selectedCategory, required this.onCategorySelected, }); @@ -87,12 +84,11 @@ class _CategoryCard extends StatelessWidget { class _CreateNewProposalCategorySelectionState extends State { late final ScrollController _scrollController; - CampaignCategoryDetailsViewModel? get _selectedCategory { - return widget.categories.firstWhereOrNull((element) => element.ref == widget.selectedCategory); - } - @override Widget build(BuildContext context) { + final categories = widget.categories.categories ?? []; + final selected = widget.categories.selected; + return Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -100,26 +96,26 @@ class _CreateNewProposalCategorySelectionState extends State _CategoryCard( - name: widget.categories[index].formattedName, - description: widget.categories[index].shortDescription, - ref: widget.categories[index].ref, - isSelected: widget.categories[index].ref == widget.selectedCategory, + name: categories[index].formattedName, + description: categories[index].shortDescription, + ref: categories[index].ref, + isSelected: categories[index].ref == selected?.ref, onCategorySelected: widget.onCategorySelected, ), separatorBuilder: (context, index) => const SizedBox(height: 16), - itemCount: widget.categories.length, + itemCount: categories.length, ), ), const SizedBox(width: 16), Expanded( flex: 2, - child: _selectedCategory != null + child: selected != null ? VoicesScrollbar( controller: _scrollController, alwaysVisible: true, child: SingleChildScrollView( controller: _scrollController, - child: CategoryCompactDetailView(category: _selectedCategory!), + child: CategoryCompactDetailView(category: selected), ), ) : const _NoneCategorySelected(), diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_dialog.dart b/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_dialog.dart index 8dfd63840fe1..8a10da2baf74 100644 --- a/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_dialog.dart +++ b/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_dialog.dart @@ -14,11 +14,6 @@ import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -typedef _SelectedCategoryData = ({ - List categories, - SignedDocumentRef? value, -}); - class CreateNewProposalDialog extends StatefulWidget { final SignedDocumentRef? categoryRef; @@ -165,17 +160,11 @@ class _ProposalCategory extends StatelessWidget { text: context.l10n.selectCategory, description: context.l10n.categorySelectionDescription, ), - BlocSelector( - selector: (state) { - return ( - categories: state.categories, - value: state.categoryRef, - ); - }, + BlocSelector( + selector: (state) => state.categories, builder: (context, state) { return CreateNewProposalCategorySelection( - categories: state.categories, - selectedCategory: state.value, + categories: state, onCategorySelected: (value) => context.read().updateSelectedCategory(value), ); diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart index 21d1ea02d390..7074f0c7dac6 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart @@ -87,6 +87,7 @@ class NewProposalCubit extends Cubit } Future load({SignedDocumentRef? categoryRef}) async { + print('load.categoryRef -> $categoryRef'); _cache = _cache.copyWith(categoryRef: Optional(categoryRef)); emit(NewProposalState.loading()); @@ -118,11 +119,14 @@ class NewProposalCubit extends Cubit } void updateSelectedCategory(SignedDocumentRef? categoryRef) { + _cache = _cache.copyWith(categoryRef: Optional(categoryRef)); + emit( state.copyWith( - categoryRef: Optional(categoryRef), isAgreeToCategoryCriteria: false, isAgreeToNoFurtherCategoryChange: false, + categoryRef: Optional(categoryRef), + categories: state.categories.copyWith(selectedRef: Optional(categoryRef)), ), ); } @@ -183,12 +187,12 @@ class NewProposalCubit extends Cubit // right now user can start creating proposal without selecting category. // Right now every category have the same requirements for title so we can do a fallback for // first category from the list. - final categories = campaign?.categories ?? []; - final templateRef = categories + final campaignCategories = campaign?.categories ?? []; + final templateRef = campaignCategories .cast() .firstWhere( (e) => e?.selfRef == preselectedCategory, - orElse: () => categories.firstOrNull, + orElse: () => campaignCategories.firstOrNull, ) ?.proposalTemplateRef; @@ -197,7 +201,7 @@ class NewProposalCubit extends Cubit : null; final titleRange = template?.title?.strLengthRange; - final stateCategories = categories.map( + final mappedCategories = campaignCategories.map( (category) { final categoryTotalAsk = campaignTotalAsk.categoriesAsks[category.selfRef] ?? @@ -211,6 +215,11 @@ class NewProposalCubit extends Cubit }, ).toList(); + final categoriesState = NewProposalStateCategories( + categories: mappedCategories, + selectedRef: _cache.categoryRef, + ); + final step = _cache.categoryRef == null ? const CreateProposalWithoutPreselectedCategoryStep() : const CreateProposalWithPreselectedCategoryStep(); @@ -220,7 +229,7 @@ class NewProposalCubit extends Cubit step: step, categoryRef: Optional(_cache.categoryRef), titleLengthRange: Optional(titleRange), - categories: stateCategories, + categories: categoriesState, ); if (!isClosed) { diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_state.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_state.dart index 89c62f4812f2..287e3ec1f20f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_state.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_state.dart @@ -13,7 +13,7 @@ class NewProposalState extends Equatable { final ProposalTitle title; final Range? titleLengthRange; final SignedDocumentRef? categoryRef; - final List categories; + final NewProposalStateCategories categories; const NewProposalState({ this.isLoading = false, @@ -24,7 +24,7 @@ class NewProposalState extends Equatable { required this.title, this.titleLengthRange, this.categoryRef, - this.categories = const [], + this.categories = const NewProposalStateCategories(), }); factory NewProposalState.loading() { @@ -49,8 +49,7 @@ class NewProposalState extends Equatable { categories, ]; - String? get selectedCategoryName => - categories.firstWhereOrNull((e) => e.ref == categoryRef)?.formattedName; + String? get selectedCategoryName => categories.selected?.formattedName; bool get _isAgreementValid => isAgreeToCategoryCriteria && isAgreeToNoFurtherCategoryChange; @@ -64,7 +63,7 @@ class NewProposalState extends Equatable { ProposalTitle? title, Optional>? titleLengthRange, Optional? categoryRef, - List? categories, + NewProposalStateCategories? categories, }) { return NewProposalState( isLoading: isLoading ?? this.isLoading, @@ -80,3 +79,32 @@ class NewProposalState extends Equatable { ); } } + +final class NewProposalStateCategories extends Equatable { + final List? categories; + final SignedDocumentRef? selectedRef; + + CampaignCategoryDetailsViewModel? get selected => + categories?.firstWhereOrNull((element) => element.ref.id == selectedRef?.id); + + const NewProposalStateCategories({ + this.categories, + this.selectedRef, + }); + + NewProposalStateCategories copyWith({ + Optional>? categories, + Optional? selectedRef, + }) { + return NewProposalStateCategories( + categories: categories.dataOr(this.categories), + selectedRef: selectedRef.dataOr(this.selectedRef), + ); + } + + @override + List get props => [ + categories, + selectedRef, + ]; +} From bb50abdfc610ba88fbb679dce9673650373c9e5b Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Tue, 18 Nov 2025 11:57:34 +0100 Subject: [PATCH 15/30] remove print and add TODO --- .../lib/widgets/modals/proposals/create_new_proposal_dialog.dart | 1 + .../src/proposal_builder/new_proposal/new_proposal_cubit.dart | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_dialog.dart b/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_dialog.dart index 8a10da2baf74..884add2f7bf5 100644 --- a/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_dialog.dart +++ b/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_dialog.dart @@ -14,6 +14,7 @@ import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +// TODO(damian-molinski): this widget have to be refactored into smaller files. class CreateNewProposalDialog extends StatefulWidget { final SignedDocumentRef? categoryRef; diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart index 7074f0c7dac6..71987dd2f433 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart @@ -87,7 +87,6 @@ class NewProposalCubit extends Cubit } Future load({SignedDocumentRef? categoryRef}) async { - print('load.categoryRef -> $categoryRef'); _cache = _cache.copyWith(categoryRef: Optional(categoryRef)); emit(NewProposalState.loading()); From d977e5caf992842f787440e38f8f1ef96e7acbc6 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Tue, 18 Nov 2025 12:35:09 +0100 Subject: [PATCH 16/30] documents getLatestOf --- .../src/database/dao/documents_v2_dao.dart | 24 ++++ .../lib/src/document/document_repository.dart | 14 +++ .../database_documents_data_source.dart | 5 + .../source/database_drafts_data_source.dart | 6 + .../source/document_data_remote_source.dart | 10 ++ .../document/source/document_data_source.dart | 2 + .../database/dao/documents_v2_dao_test.dart | 118 ++++++++++++++++++ .../lib/src/proposal/proposal_service.dart | 16 +-- 8 files changed, 185 insertions(+), 10 deletions(-) diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart index ad99619c382d..bac519b03dc8 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart @@ -35,6 +35,12 @@ abstract interface class DocumentsV2Dao { /// Returns null if no matching document is found. Future getDocument(DocumentRef ref); + /// Finds the latest version of a document. + /// + /// Takes a [ref] (which can be loose or exact) and returns a [DocumentRef] + /// pointing to the latest known version of that document. + Future getLatestOf(DocumentRef ref); + /// Saves a single document, ignoring if it conflicts on {id, ver}. /// /// Delegates to [saveAll] for consistent conflict handling and reuse. @@ -146,6 +152,24 @@ class DriftDocumentsV2Dao extends DatabaseAccessor return query.getSingleOrNull(); } + @override + Future getLatestOf(DocumentRef ref) { + final query = selectOnly(documentsV2) + ..addColumns([documentsV2.id, documentsV2.ver]) + ..where(documentsV2.id.equals(ref.id)) + ..orderBy([OrderingTerm.desc(documentsV2.createdAt)]) + ..limit(1); + + return query + .map( + (row) => SignedDocumentRef.exact( + id: row.read(documentsV2.id)!, + version: row.read(documentsV2.ver)!, + ), + ) + .getSingleOrNull(); + } + @override Future save(DocumentWithAuthorsEntity entity) => saveAll([entity]); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart index 88b40af24f4b..1f4e5bfaaf0d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart @@ -85,6 +85,9 @@ abstract interface class DocumentRepository { CatalystId? authorId, }); + /// Returns latest matching [DocumentRef] version with same id as [ref]. + Future getLatestOf({required DocumentRef ref}); + /// Returns count of documents matching [ref] id and [type]. Future getRefCount({ required DocumentRef ref, @@ -320,6 +323,17 @@ final class DocumentRepositoryImpl implements DocumentRepository { return [latestDocument, latestDraft].nonNulls.sorted((a, b) => a.compareTo(b)).firstOrNull; } + // TODO(damian-molinski): consider also checking with remote source. + @override + Future getLatestOf({required DocumentRef ref}) async { + final draft = await _drafts.getLatestOf(ref: ref); + if (draft != null) { + return draft; + } + + return _localDocuments.getLatestOf(ref: ref); + } + @override Future getRefCount({ required DocumentRef ref, 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 3fd8d9597fc9..49f7a91e63f4 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 @@ -69,6 +69,11 @@ final class DatabaseDocumentsDataSource .then((value) => value?.toModel()); } + @override + Future getLatestOf({required DocumentRef ref}) { + return _database.documentsV2Dao.getLatestOf(ref); + } + @override Future> getProposals({ SignedDocumentRef? categoryRef, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart index 50ea2071b4af..a18412ea1547 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart @@ -50,6 +50,12 @@ final class DatabaseDraftsDataSource implements DraftDataSource { return _database.draftsDao.queryLatest(authorId: authorId).then((value) => value?.toModel()); } + @override + Future getLatestOf({required DocumentRef ref}) async { + // TODO(damian-molinski): not implemented + return null; + } + @override Future> queryVersionsOfId({required String id}) async { final documentEntities = await _database.draftsDao.queryVersionsOfId(id: id); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_remote_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_remote_source.dart index 19cb52637359..cd8db83d71c0 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_remote_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_remote_source.dart @@ -30,6 +30,16 @@ final class CatGatewayDocumentDataSource implements DocumentDataRemoteSource { return DocumentDataFactory.create(signedDocument); } + @override + Future getLatestOf({required DocumentRef ref}) async { + final ver = await getLatestVersion(ref.id); + if (ver == null) { + return null; + } + + return SignedDocumentRef(id: ref.id, version: ver); + } + @override Future getLatestVersion(String id) { final ver = allConstantDocumentRefs diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart index 4ff8140690db..2135bb86bdf8 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart @@ -3,4 +3,6 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; //ignore: one_member_abstracts abstract interface class DocumentDataSource { Future get({required DocumentRef ref}); + + Future getLatestOf({required DocumentRef ref}); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart index ee0969b625c1..6fcf37bfc12e 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart @@ -892,6 +892,124 @@ void main() { await expectation; }); }); + + group('getLatestOf', () { + test('returns null for non-existing id in empty database', () async { + // Given + const ref = SignedDocumentRef.exact(id: 'non-existent-id', version: 'non-existent-ver'); + + // When + final result = await dao.getLatestOf(ref); + + // Then + expect(result, isNull); + }); + + test('returns the document ref when only one version exists', () async { + // Given + final entity = _createTestDocumentEntity(id: 'test-id', ver: 'test-ver'); + await dao.save(entity); + + // And + const ref = SignedDocumentRef.loose(id: 'test-id'); + + // When + final result = await dao.getLatestOf(ref); + + // Then + expect(result, isNotNull); + expect(result!.id, 'test-id'); + expect(result.version, 'test-ver'); + expect(result.isExact, isTrue); + }); + + test('returns latest version when multiple versions exist (loose ref input)', () async { + // Given + final oldCreatedAt = DateTime.utc(2023, 1, 1); + final newerCreatedAt = DateTime.utc(2024, 6, 15); + + final oldVer = _buildUuidV7At(oldCreatedAt); + final newerVer = _buildUuidV7At(newerCreatedAt); + final entityOld = _createTestDocumentEntity(id: 'test-id', ver: oldVer); + final entityNew = _createTestDocumentEntity(id: 'test-id', ver: newerVer); + await dao.saveAll([entityOld, entityNew]); + + // And + const ref = SignedDocumentRef.loose(id: 'test-id'); + + // When + final result = await dao.getLatestOf(ref); + + // Then + expect(result, isNotNull); + expect(result!.id, 'test-id'); + expect(result.version, newerVer); + }); + + test('returns latest version even when exact ref points to older version', () async { + // Given + final oldCreatedAt = DateTime.utc(2023, 1, 1); + final newerCreatedAt = DateTime.utc(2024, 6, 15); + + final oldVer = _buildUuidV7At(oldCreatedAt); + final newerVer = _buildUuidV7At(newerCreatedAt); + final entityOld = _createTestDocumentEntity(id: 'test-id', ver: oldVer); + final entityNew = _createTestDocumentEntity(id: 'test-id', ver: newerVer); + await dao.saveAll([entityOld, entityNew]); + + // And: exact ref pointing to older version + final ref = SignedDocumentRef.exact(id: 'test-id', version: oldVer); + + // When + final result = await dao.getLatestOf(ref); + + // Then: still returns the latest version + expect(result, isNotNull); + expect(result!.id, 'test-id'); + expect(result.version, newerVer); + }); + + test('returns null for non-existing id when other documents exist', () async { + // Given + final entity = _createTestDocumentEntity(id: 'other-id', ver: 'other-ver'); + await dao.save(entity); + + // And + const ref = SignedDocumentRef.loose(id: 'non-existent-id'); + + // When + final result = await dao.getLatestOf(ref); + + // Then + expect(result, isNull); + }); + + test('returns latest among many versions', () async { + // Given + final dates = [ + DateTime.utc(2023, 1, 1), + DateTime.utc(2023, 6, 15), + DateTime.utc(2024, 3, 10), + DateTime.utc(2024, 12, 25), + DateTime.utc(2024, 8, 1), + ]; + final versions = dates.map(_buildUuidV7At).toList(); + final entities = versions + .map((ver) => _createTestDocumentEntity(id: 'multi-ver-id', ver: ver)) + .toList(); + await dao.saveAll(entities); + + // And + const ref = SignedDocumentRef.loose(id: 'multi-ver-id'); + + // When + final result = await dao.getLatestOf(ref); + + // Then: returns the version with latest createdAt (2024-12-25) + expect(result, isNotNull); + expect(result!.version, versions[3]); + }); + }); }); } 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 deec6276701f..fe3d6377179f 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 @@ -250,17 +250,13 @@ final class ProposalServiceImpl implements ProposalService { } @override - Future getLatestProposalVersion({ - required DocumentRef ref, - }) async { - final proposalVersions = await _documentRepository.getAllVersionsOfId( - id: ref.id, - ); - final refList = List.from( - proposalVersions.map((e) => e.metadata.selfRef).toList(), - )..sort(); + Future getLatestProposalVersion({required DocumentRef ref}) async { + final latest = await _documentRepository.getLatestOf(ref: ref); + if (latest == null) { + throw DocumentNotFoundException(ref: ref); + } - return refList.last; + return latest; } @override From e55433fd382b42d9a2a699287dd917e4eaadf42d Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Tue, 18 Nov 2025 13:57:50 +0100 Subject: [PATCH 17/30] remove old tables and daos --- .../apps/voices/lib/configs/bootstrap.dart | 4 +- .../voices/lib/dependency/dependencies.dart | 7 +- .../lib/src/proposal/proposal_cubit.dart | 4 +- .../lib/src/catalyst_voices_dev.dart | 3 +- .../document/document_data_factory.dart | 22 + .../document/document_factories.dart | 163 -- .../document/document_ref_factory.dart | 44 + .../lib/src/catalyst_voices_repositories.dart | 1 - .../lib/src/database/catalyst_database.dart | 52 +- .../lib/src/database/dao/documents_dao.dart | 491 ---- .../dao/documents_v2_local_metadata_dao.dart | 50 + .../lib/src/database/dao/drafts_dao.dart | 253 -- .../lib/src/database/dao/favorites_dao.dart | 103 - .../lib/src/database/dao/proposals_dao.dart | 836 ------- .../lib/src/database/dao/workspace_dao.dart | 32 + .../lib/src/database/database.dart | 15 +- .../model/joined_proposal_entity.dart | 30 - .../src/database/query/jsonb_expressions.dart | 200 -- .../table/converter/document_converters.dart | 11 - .../lib/src/database/table/documents.dart | 24 - .../database/table/documents_favorite.dart | 18 - .../database/table/documents_metadata.dart | 41 - .../lib/src/database/table/drafts.dart | 29 - .../table/mixin/document_table_mixin.dart | 18 - .../database/table/mixin/id_table_mixin.dart | 11 - .../database/table/mixin/ver_table_mixin.dart | 11 - .../lib/src/database/typedefs.dart | 8 - .../lib/src/document/document_repository.dart | 63 +- .../source/document_favorites_source.dart | 60 - .../src/database/catalyst_database_test.dart | 19 +- .../src/database/dao/documents_dao_test.dart | 1249 ---------- .../database/dao/documents_v2_dao_test.dart | 38 +- .../src/database/dao/drafts_dao_test.dart | 439 ---- .../src/database/dao/proposals_dao_test.dart | 2150 ----------------- .../database/dao/proposals_v2_dao_test.dart | 38 +- .../query/jsonb_expressions_test.dart | 55 - .../document/document_repository_test.dart | 3 - .../utils/document_with_authors_factory.dart | 68 + .../lib/src/documents/documents_service.dart | 7 + .../lib/src/proposal/proposal_service.dart | 30 - 40 files changed, 264 insertions(+), 6436 deletions(-) create mode 100644 catalyst_voices/packages/internal/catalyst_voices_dev/lib/src/catalyst_voices_models/document/document_data_factory.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_dev/lib/src/catalyst_voices_models/document/document_factories.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_dev/lib/src/catalyst_voices_models/document/document_ref_factory.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_dao.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_local_metadata_dao.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/drafts_dao.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/favorites_dao.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/proposals_dao.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/workspace_dao.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/model/joined_proposal_entity.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/query/jsonb_expressions.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents_favorite.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents_metadata.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/drafts.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/document_table_mixin.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/id_table_mixin.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/ver_table_mixin.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/typedefs.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_favorites_source.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_dao_test.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/drafts_dao_test.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/proposals_dao_test.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/query/jsonb_expressions_test.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/utils/document_with_authors_factory.dart diff --git a/catalyst_voices/apps/voices/lib/configs/bootstrap.dart b/catalyst_voices/apps/voices/lib/configs/bootstrap.dart index 6a1848cacef8..9b75a87b7659 100644 --- a/catalyst_voices/apps/voices/lib/configs/bootstrap.dart +++ b/catalyst_voices/apps/voices/lib/configs/bootstrap.dart @@ -182,8 +182,8 @@ Future cleanUpStorages({ Future cleanUpUserDataFromDatabase() async { final db = Dependencies.instance.get(); - await db.draftsDao.deleteWhere(); - await db.favoritesDao.deleteAll(); + await db.workspaceDao.deleteLocalDrafts(); + await db.localMetadataDao.deleteWhere(); } @visibleForTesting diff --git a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart index 45e042528420..6a280a546db7 100644 --- a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart +++ b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart @@ -165,6 +165,7 @@ final class Dependencies extends DependencyProvider { get(), get(), get(), + get(), get(), get(), get(), @@ -261,11 +262,6 @@ final class Dependencies extends DependencyProvider { get(), ); }) - ..registerLazySingleton(() { - return DatabaseDocumentFavoriteSource( - get(), - ); - }) ..registerLazySingleton(() { return CatGatewayDocumentDataSource( get(), @@ -285,7 +281,6 @@ final class Dependencies extends DependencyProvider { get(), get(), get(), - get(), ); }) ..registerLazySingleton(() => const DocumentMapperImpl()) diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal/proposal_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal/proposal_cubit.dart index 4a139015633c..9be19e00e748 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal/proposal_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal/proposal_cubit.dart @@ -25,6 +25,7 @@ final class ProposalCubit extends Cubit final ProposalService _proposalService; final CommentService _commentService; final CampaignService _campaignService; + final DocumentsService _documentsService; final DocumentMapper _documentMapper; final VotingBallotBuilder _ballotBuilder; final VotingService _votingService; @@ -40,6 +41,7 @@ final class ProposalCubit extends Cubit this._proposalService, this._commentService, this._campaignService, + this._documentsService, this._documentMapper, this._ballotBuilder, this._votingService, @@ -89,7 +91,7 @@ final class ProposalCubit extends Cubit final commentTemplate = await _commentService.getCommentTemplateFor( category: proposal.document.metadata.categoryId, ); - final isFavorite = await _proposalService.watchIsFavoritesProposal(ref: ref).first; + final isFavorite = await _documentsService.isFavorite(ref); _cache = _cache.copyWith( proposal: Optional(proposal), diff --git a/catalyst_voices/packages/internal/catalyst_voices_dev/lib/src/catalyst_voices_dev.dart b/catalyst_voices/packages/internal/catalyst_voices_dev/lib/src/catalyst_voices_dev.dart index 777d2db36d05..b8293c39957e 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_dev/lib/src/catalyst_voices_dev.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_dev/lib/src/catalyst_voices_dev.dart @@ -4,7 +4,8 @@ export 'catalyst_compression/catalyst_compressor_fakes.dart'; export 'catalyst_key_derivation/bip32_ed25519_fakes.dart'; export 'catalyst_key_derivation/key_derivation_service_fakes.dart'; export 'catalyst_voices_models/crypto/catalyst_crypto_fakes.dart'; -export 'catalyst_voices_models/document/document_factories.dart'; +export 'catalyst_voices_models/document/document_data_factory.dart'; +export 'catalyst_voices_models/document/document_ref_factory.dart'; export 'catalyst_voices_repositories/api/api_mocks.dart'; export 'catalyst_voices_repositories/repository_mocks.dart'; export 'catalyst_voices_repositories/user/keychain_fakes.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_dev/lib/src/catalyst_voices_models/document/document_data_factory.dart b/catalyst_voices/packages/internal/catalyst_voices_dev/lib/src/catalyst_voices_models/document/document_data_factory.dart new file mode 100644 index 000000000000..ec21665eadb0 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_dev/lib/src/catalyst_voices_models/document/document_data_factory.dart @@ -0,0 +1,22 @@ +import 'package:catalyst_voices_dev/catalyst_voices_dev.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart' hide Document; + +abstract final class DocumentDataFactory { + static DocumentData build({ + DocumentType type = DocumentType.proposalDocument, + DocumentRef? selfRef, + SignedDocumentRef? template, + SignedDocumentRef? categoryId, + DocumentDataContent content = const DocumentDataContent({}), + }) { + return DocumentData( + metadata: DocumentDataMetadata( + type: type, + selfRef: selfRef ?? DocumentRefFactory.signedDocumentRef(), + template: template, + categoryId: categoryId, + ), + content: content, + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_dev/lib/src/catalyst_voices_models/document/document_factories.dart b/catalyst_voices/packages/internal/catalyst_voices_dev/lib/src/catalyst_voices_models/document/document_factories.dart deleted file mode 100644 index 215eaad9c5ad..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_dev/lib/src/catalyst_voices_models/document/document_factories.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'dart:math'; - -import 'package:catalyst_voices_models/catalyst_voices_models.dart' hide Document; -import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; -import 'package:uuid_plus/uuid_plus.dart'; - -abstract final class DocumentDataFactory { - static DocumentData build({ - DocumentType type = DocumentType.proposalDocument, - DocumentRef? selfRef, - SignedDocumentRef? template, - SignedDocumentRef? categoryId, - DocumentDataContent content = const DocumentDataContent({}), - }) { - return DocumentData( - metadata: DocumentDataMetadata( - type: type, - selfRef: selfRef ?? DocumentRefFactory.signedDocumentRef(), - template: template, - categoryId: categoryId, - ), - content: content, - ); - } -} - -abstract final class DocumentFactory { - static DocumentEntity build({ - DocumentDataContent? content, - DocumentDataMetadata? metadata, - DateTime? createdAt, - }) { - content ??= const DocumentDataContent({}); - - metadata ??= DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DocumentRefFactory.signedDocumentRef(), - ); - - final id = UuidHiLo.from(metadata.id); - final ver = UuidHiLo.from(metadata.version); - - return DocumentEntity( - idHi: id.high, - idLo: id.low, - verHi: ver.high, - verLo: ver.low, - type: metadata.type, - content: content, - metadata: metadata, - createdAt: createdAt ?? DateTime.timestamp(), - ); - } -} - -abstract final class DocumentMetadataFactory { - static DocumentMetadataEntity build({ - String? ver, - required DocumentMetadataFieldKey fieldKey, - required String fieldValue, - }) { - ver ??= DocumentRefFactory.randomUuidV7(); - final verHiLo = UuidHiLo.from(ver); - - return DocumentMetadataEntity( - verHi: verHiLo.high, - verLo: verHiLo.low, - fieldKey: fieldKey, - fieldValue: fieldValue, - ); - } -} - -abstract final class DocumentRefFactory { - static final Random _random = Random(57342052346526); - static var _timestamp = DateTime.now().millisecondsSinceEpoch; - - static DraftRef draftRef() { - return DraftRef.first(randomUuidV7()); - } - - static String randomUuidV7() { - return const UuidV7().generate( - options: V7Options( - _randomDateTime().millisecondsSinceEpoch, - _randomBytes(10), - ), - ); - } - - static SignedDocumentRef signedDocumentRef() { - return SignedDocumentRef.first(randomUuidV7()); - } - - static List _randomBytes(int length) { - return List.generate(length, (index) => _random.nextInt(256)); - } - - static DateTime _randomDateTime() { - final timestamp = _timestamp; - _timestamp++; - - return DateTime.fromMillisecondsSinceEpoch(timestamp); - } -} - -abstract final class DocumentWithMetadataFactory { - static DocumentEntityWithMetadata build({ - DocumentDataContent? content, - DocumentDataMetadata? metadata, - DateTime? createdAt, - }) { - final document = DocumentFactory.build( - content: content, - metadata: metadata, - createdAt: createdAt, - ); - - final documentMetadata = DocumentMetadataFieldKey.values.map((fieldKey) { - return switch (fieldKey) { - DocumentMetadataFieldKey.title => DocumentMetadataFactory.build( - ver: document.metadata.version, - fieldKey: fieldKey, - - fieldValue: 'Document[${document.metadata.version}] title', - ), - }; - }).toList(); - - return (document: document, metadata: documentMetadata); - } -} - -abstract final class DraftFactory { - static DocumentDraftEntity build({ - DocumentDataContent? content, - DocumentDataMetadata? metadata, - String? title, - }) { - content ??= const DocumentDataContent({}); - - metadata ??= DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DocumentRefFactory.draftRef(), - ); - - title ??= 'Draft[${metadata.id}] title'; - - final id = UuidHiLo.from(metadata.id); - final ver = UuidHiLo.from(metadata.version); - - return DocumentDraftEntity( - idHi: id.high, - idLo: id.low, - verHi: ver.high, - verLo: ver.low, - type: metadata.type, - content: content, - metadata: metadata, - title: title, - ); - } -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_dev/lib/src/catalyst_voices_models/document/document_ref_factory.dart b/catalyst_voices/packages/internal/catalyst_voices_dev/lib/src/catalyst_voices_models/document/document_ref_factory.dart new file mode 100644 index 000000000000..8e3db8bb34f1 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_dev/lib/src/catalyst_voices_models/document/document_ref_factory.dart @@ -0,0 +1,44 @@ +import 'dart:math'; + +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:flutter/foundation.dart'; +import 'package:uuid_plus/uuid_plus.dart'; + +abstract final class DocumentRefFactory { + static final Random _random = Random(57342052346526); + static var _timestamp = DateTime.now().millisecondsSinceEpoch; + + static DraftRef draftRef() { + return DraftRef.first(randomUuidV7()); + } + + static String randomUuidV7() { + return const UuidV7().generate( + options: V7Options( + _randomDateTime().millisecondsSinceEpoch, + _randomBytes(10), + ), + ); + } + + static SignedDocumentRef signedDocumentRef() { + return SignedDocumentRef.first(randomUuidV7()); + } + + static String uuidV7At(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)); + } + + static List _randomBytes(int length) { + return List.generate(length, (index) => _random.nextInt(256)); + } + + static DateTime _randomDateTime() { + final timestamp = _timestamp; + _timestamp++; + + return DateTime.fromMillisecondsSinceEpoch(timestamp); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart index d53be17fc0b0..d7bdd14caad8 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart @@ -18,7 +18,6 @@ export 'document/source/database_drafts_data_source.dart'; export 'document/source/document_data_local_source.dart'; export 'document/source/document_data_remote_source.dart'; export 'document/source/document_data_source.dart'; -export 'document/source/document_favorites_source.dart'; export 'dto/document/document_dto.dart' show DocumentExt; export 'logging/logging_settings_storage.dart'; export 'proposal/proposal_repository.dart' show ProposalRepository; diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/catalyst_database.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/catalyst_database.dart index 63ff2104eaf5..59c74b6a5f8e 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/catalyst_database.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/catalyst_database.dart @@ -1,21 +1,13 @@ import 'package:catalyst_voices_repositories/src/database/catalyst_database.drift.dart'; import 'package:catalyst_voices_repositories/src/database/catalyst_database_config.dart'; -import 'package:catalyst_voices_repositories/src/database/dao/documents_dao.dart'; import 'package:catalyst_voices_repositories/src/database/dao/documents_v2_dao.dart'; -import 'package:catalyst_voices_repositories/src/database/dao/drafts_dao.dart'; -import 'package:catalyst_voices_repositories/src/database/dao/favorites_dao.dart'; -import 'package:catalyst_voices_repositories/src/database/dao/proposals_dao.dart'; +import 'package:catalyst_voices_repositories/src/database/dao/documents_v2_local_metadata_dao.dart'; import 'package:catalyst_voices_repositories/src/database/dao/proposals_v2_dao.dart'; +import 'package:catalyst_voices_repositories/src/database/dao/workspace_dao.dart'; import 'package:catalyst_voices_repositories/src/database/migration/drift_migration_strategy.dart'; import 'package:catalyst_voices_repositories/src/database/table/document_authors.dart'; -import 'package:catalyst_voices_repositories/src/database/table/documents.dart'; -import 'package:catalyst_voices_repositories/src/database/table/documents.drift.dart'; -import 'package:catalyst_voices_repositories/src/database/table/documents_favorite.dart'; import 'package:catalyst_voices_repositories/src/database/table/documents_local_metadata.dart'; -import 'package:catalyst_voices_repositories/src/database/table/documents_metadata.dart'; import 'package:catalyst_voices_repositories/src/database/table/documents_v2.dart'; -import 'package:catalyst_voices_repositories/src/database/table/drafts.dart'; -import 'package:catalyst_voices_repositories/src/database/table/drafts.drift.dart'; import 'package:catalyst_voices_repositories/src/database/table/local_documents_drafts.dart'; import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; @@ -34,18 +26,9 @@ abstract interface class CatalystDatabase { QueryInterceptor? interceptor, }) = DriftCatalystDatabase.withConfig; - /// Contains all operations related to [DocumentEntity] which is db specific. - /// Do not confuse it with other documents. - DocumentsDao get documentsDao; - DocumentsV2Dao get documentsV2Dao; - /// Contains all operations related to [DocumentDraftEntity] which is db - /// specific. Do not confuse it with other documents / drafts. - DraftsDao get draftsDao; - - /// Contains all operations related to fav status of documents. - FavoritesDao get favoritesDao; + DocumentsV2LocalMetadataDao get localMetadataDao; /// Allows to await completion of pending operations. /// @@ -53,11 +36,10 @@ abstract interface class CatalystDatabase { @visibleForTesting Future get pendingOperations; - /// Specialized version of [DocumentsDao]. - ProposalsDao get proposalsDao; - ProposalsV2Dao get proposalsV2Dao; + WorkspaceDao get workspaceDao; + Future analyze(); /// Removes all data from this db. @@ -71,22 +53,16 @@ abstract interface class CatalystDatabase { @DriftDatabase( tables: [ - Documents, - DocumentsMetadata, - DocumentsFavorites, - Drafts, DocumentsV2, DocumentAuthors, DocumentsLocalMetadata, LocalDocumentsDrafts, ], daos: [ - DriftDocumentsDao, - DriftFavoritesDao, - DriftDraftsDao, - DriftProposalsDao, DriftDocumentsV2Dao, DriftProposalsV2Dao, + DriftDocumentsV2LocalMetadataDao, + DriftWorkspaceDao, ], queries: {}, views: [], @@ -121,17 +97,11 @@ class DriftCatalystDatabase extends $DriftCatalystDatabase implements CatalystDa return DriftCatalystDatabase(connection); } - @override - DocumentsDao get documentsDao => driftDocumentsDao; - @override DocumentsV2Dao get documentsV2Dao => driftDocumentsV2Dao; @override - DraftsDao get draftsDao => driftDraftsDao; - - @override - FavoritesDao get favoritesDao => driftFavoritesDao; + DocumentsV2LocalMetadataDao get localMetadataDao => driftDocumentsV2LocalMetadataDao; @override MigrationStrategy get migration { @@ -147,15 +117,15 @@ class DriftCatalystDatabase extends $DriftCatalystDatabase implements CatalystDa await customSelect('select 1').get(); } - @override - ProposalsDao get proposalsDao => driftProposalsDao; - @override ProposalsV2Dao get proposalsV2Dao => driftProposalsV2Dao; @override int get schemaVersion => 4; + @override + WorkspaceDao get workspaceDao => driftWorkspaceDao; + @override Future analyze() async { await customStatement('ANALYZE'); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_dao.dart deleted file mode 100644 index ca4d30040b85..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_dao.dart +++ /dev/null @@ -1,491 +0,0 @@ -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; -import 'package:catalyst_voices_repositories/src/database/dao/documents_dao.drift.dart'; -import 'package:catalyst_voices_repositories/src/database/query/jsonb_expressions.dart'; -import 'package:catalyst_voices_repositories/src/database/table/documents.dart'; -import 'package:catalyst_voices_repositories/src/database/table/documents.drift.dart'; -import 'package:catalyst_voices_repositories/src/database/table/documents_metadata.dart'; -import 'package:catalyst_voices_repositories/src/database/table/drafts.dart'; -import 'package:catalyst_voices_repositories/src/database/typedefs.dart'; -import 'package:collection/collection.dart'; -import 'package:drift/drift.dart'; -import 'package:drift/extensions/json1.dart'; -import 'package:flutter/foundation.dart'; - -/// Exposes only public operation on documents, and related, tables. -abstract interface class DocumentsDao { - /// Counts documents matching required [ref] id and optional [ref] ver. - /// - /// If [ref] is null counts all documents. - /// - /// If [ref] ver is not specified it will return count of all version - /// matching [ref] id. - Future count({DocumentRef? ref}); - - /// Counts unique documents. All versions of same document are counted as 1. - Future countDocuments(); - - @visibleForTesting - Future countDocumentsMetadata(); - - /// Counts documents of specified [type] - /// that reference a given document [ref]. - /// - /// [ref] is the reference to the parent document being referenced - /// [type] is the type of documents to count (e.g., comments, reactions, etc.) - /// - /// Returns the count of documents matching both the type and reference. - Future countRefDocumentByType({ - required DocumentRef ref, - required DocumentType type, - }); - - /// Deletes all documents. Cascades to metadata. - /// - /// If [keepTemplatesForLocalDrafts] is true keeps templates referred by local drafts. - Future deleteAll({ - bool keepTemplatesForLocalDrafts, - }); - - /// If version is specified in [ref] returns this version or null. - /// Returns newest version with matching id or null of none found. - Future query({required DocumentRef ref}); - - /// Returns all entities. If same document have different versions - /// all will be returned. - /// - /// Optionally matching [ref] or [type]. - Future> queryAll({ - DocumentRef? ref, - DocumentType? type, - }); - - Future queryLatestDocumentData({ - CatalystId? authorId, - }); - - /// Returns document with matching refTo and type. - /// It return only lates version of document matching [refTo] - Future queryRefToDocumentData({ - required DocumentRef refTo, - DocumentType? type, - }); - - /// Returns a list of version of ref object. - /// Can be used to get versions count. - Future> queryVersionsOfId({required String id}); - - /// Inserts all documents and metadata. On conflicts ignores duplicates. - Future saveAll( - Iterable documentsWithMetadata, - ); - - /// Same as [query] but emits updates. - Stream watch({required DocumentRef ref}); - - /// Similar to [queryAll] but emits when new records are inserted or deleted. - /// Returns all entities. If same document have different versions - /// all will be returned unless [unique] is true. - /// When [unique] is true, only latest versions of each document are returned. - /// Optional [limit] parameter limits the number of returned documents. - Stream> watchAll({ - bool unique = false, - int? limit, - DocumentType? type, - CatalystId? authorId, - DocumentRef? refTo, - }); - - /// Watches for new comments that are reference by ref. - Stream watchCount({ - DocumentRef? refTo, - DocumentType? type, - }); - - Stream watchRefToDocumentData({ - required DocumentRef refTo, - required DocumentType type, - }); -} - -@DriftAccessor( - tables: [ - Documents, - DocumentsMetadata, - Drafts, - ], -) -class DriftDocumentsDao extends DatabaseAccessor - with $DriftDocumentsDaoMixin - implements DocumentsDao { - DriftDocumentsDao(super.attachedDatabase); - - @override - Future count({DocumentRef? ref}) { - if (ref == null) { - return documents.count().getSingle(); - } else { - return documents.count(where: (row) => _filterRef(row, ref)).getSingle(); - } - } - - @override - Future countDocuments() { - final count = documents.idHi.count(distinct: true); - - final select = selectOnly(documents) - ..addColumns([ - documents.idHi, - documents.idLo, - count, - ]); - - return select.map((row) => row.read(count)).get().then((count) => count.firstOrNull ?? 0); - } - - @override - Future countDocumentsMetadata() { - final count = documentsMetadata.verHi.count(distinct: true); - - final select = selectOnly(documentsMetadata) - ..addColumns([ - documentsMetadata.verHi, - documentsMetadata.verLo, - count, - ]); - - return select.map((row) => row.read(count)).get().then((count) => count.firstOrNull ?? 0); - } - - @override - Future countRefDocumentByType({ - required DocumentRef ref, - required DocumentType type, - }) async { - final query = select(documents) - ..where( - (row) => Expression.and([ - row.metadata.jsonExtract(r'$.type').equals(type.uuid), - row.metadata.jsonExtract(r'$.ref.id').equals(ref.id), - if (ref.version != null) - row.metadata.jsonExtract(r'$.ref.version').equals(ref.version!), - ]), - ); - - final docs = await query.get(); - return docs.length; - } - - @override - Future deleteAll({ - bool keepTemplatesForLocalDrafts = false, - }) async { - final query = delete(documents); - - if (keepTemplatesForLocalDrafts) { - final templateId = drafts.metadata.jsonExtract(r'$.template.id'); - - query.where((documents) { - return notExistsQuery( - selectOnly(drafts, distinct: true) - ..addColumns([ - templateId, - ]) - ..where( - documents.metadata.jsonExtract(r'$.selfRef.id').equalsExp(templateId), - ), - ); - }); - } - - final deletedRows = await query.go(); - - if (kDebugMode) { - debugPrint('DocumentsDao: Deleted[$deletedRows] rows'); - } - - return deletedRows; - } - - @override - Future query({required DocumentRef ref}) { - return _selectRef(ref).get().then((value) => value.firstOrNull); - } - - @override - Future> queryAll({ - DocumentRef? ref, - DocumentType? type, - }) { - final query = select(documents); - - if (ref != null) { - query.where((tbl) => _filterRef(tbl, ref, filterVersion: false)); - } - if (type != null) { - query.where((doc) => doc.type.equals(type.uuid)); - } - - return query.get(); - } - - @visibleForTesting - Future> queryDocumentsByMatchedDocumentNodeIdValue({ - required DocumentNodeId nodeId, - required String value, - DocumentType? type, - required String content, - }) async { - final query = select(documents) - ..where( - (tbl) => BaseJsonQueryExpression( - jsonContent: content, - nodeId: nodeId, - searchValue: value, - ), - ); - - if (type != null) { - query.where((doc) => doc.type.equals(type.uuid)); - } - - return query.get(); - } - - @override - Future queryLatestDocumentData({ - CatalystId? authorId, - }) { - final query = select(documents) - ..orderBy([(t) => OrderingTerm.desc(t.verHi)]) - ..limit(1); - - if (authorId != null) { - query.where((tbl) => tbl.metadata.isAuthor(authorId)); - } - - return query.getSingleOrNull(); - } - - @override - Future queryRefToDocumentData({ - required DocumentRef refTo, - DocumentType? type, - }) async { - final query = select(documents) - ..where( - (row) => Expression.and([ - if (type != null) row.type.equals(type.uuid), - row.metadata.jsonExtract(r'$.ref.id').equals(refTo.id), - if (refTo.version != null) - row.metadata.jsonExtract(r'$.ref.version').equals(refTo.version!), - ]), - ) - ..orderBy([ - (u) => OrderingTerm.desc(u.verHi), - ]) - ..limit(1); - - return query.getSingleOrNull(); - } - - @override - Future> queryVersionsOfId({required String id}) { - final query = select(documents) - ..where( - (tbl) => _filterRef( - tbl, - SignedDocumentRef(id: id), - filterVersion: false, - ), - ) - ..orderBy([ - (u) => OrderingTerm.desc(u.verHi), - ]); - - return query.get(); - } - - @override - Future saveAll( - Iterable documentsWithMetadata, - ) async { - final documents = documentsWithMetadata.map((e) => e.document); - final metadata = documentsWithMetadata.expand((e) => e.metadata); - - await batch((batch) { - batch - ..insertAll( - this.documents, - documents, - mode: InsertMode.insertOrIgnore, - ) - ..insertAll( - documentsMetadata, - metadata, - mode: InsertMode.insertOrIgnore, - ); - }); - } - - @override - Stream watch({required DocumentRef ref}) { - return _selectRef(ref).watch().map((event) => event.firstOrNull).distinct(_entitiesEquals); - } - - /// When [unique] is true, only latest versions of each document are returned. - @override - Stream> watchAll({ - bool unique = false, - int? limit, - DocumentType? type, - CatalystId? authorId, - DocumentRef? refTo, - }) { - final query = select(documents); - - if (type != null) { - query.where((doc) => doc.type.equals(type.uuid)); - } - if (authorId != null) { - query.where((tbl) => tbl.metadata.isAuthor(authorId)); - } - if (refTo != null) { - query.where( - (row) => Expression.and([ - row.metadata.jsonExtract(r'$.ref.id').equals(refTo.id), - if (refTo.version != null) - row.metadata.jsonExtract(r'$.ref.version').equals(refTo.version!), - ]), - ); - } - - query.orderBy([ - (t) => OrderingTerm( - expression: t.verHi, - mode: OrderingMode.desc, - ), - ]); - - if (unique) { - final latestDocumentRef = alias(documents, 'latestDocumentRef'); - final maxVerHi = latestDocumentRef.verHi.max(); - final latestDocumentQuery = selectOnly(latestDocumentRef, distinct: true) - ..addColumns([ - latestDocumentRef.idHi, - latestDocumentRef.idLo, - maxVerHi, - latestDocumentRef.verLo, - ]) - ..where(latestDocumentRef.type.equalsValue(DocumentType.proposalDocument)) - ..groupBy([latestDocumentRef.idHi + latestDocumentRef.idLo]); - - final verSubquery = Subquery(latestDocumentQuery, 'latestDocumentRef'); - - final uniqueQuery = query.join([ - innerJoin( - verSubquery, - Expression.and([ - verSubquery.ref(maxVerHi).equalsExp(documents.verHi), - verSubquery.ref(latestDocumentRef.verLo).equalsExp(documents.verLo), - ]), - useColumns: false, - ), - ]); - - if (limit != null) { - uniqueQuery.limit(limit); - } - - return uniqueQuery.map((row) => row.readTable(documents)).watch(); - } - - if (limit != null) { - query.limit(limit); - } - return query.watch(); - } - - @override - Stream watchCount({ - DocumentRef? refTo, - DocumentType? type, - }) { - final query = select(documents) - ..where( - (row) { - return Expression.and([ - if (type != null) row.metadata.jsonExtract(r'$.type').equals(type.uuid), - if (refTo != null) row.metadata.jsonExtract(r'$.ref.id').equals(refTo.id), - if (refTo?.version != null) - row.metadata.jsonExtract(r'$.ref.version').equals(refTo!.version!), - ]); - }, - ); - - return query.watch().map((comments) => comments.length).distinct(); - } - - @override - Stream watchRefToDocumentData({ - required DocumentRef refTo, - required DocumentType type, - }) { - final query = select(documents) - ..where( - (row) => Expression.and([ - row.metadata.jsonExtract(r'$.type').equals(type.uuid), - row.metadata.jsonExtract(r'$.ref.id').equals(refTo.id), - if (refTo.version != null) - row.metadata.jsonExtract(r'$.ref.version').equals(refTo.version!), - ]), - ) - ..orderBy([ - (t) => OrderingTerm( - expression: t.verHi, - mode: OrderingMode.desc, - ), - ]); - - return query.watch().map((event) => event.firstOrNull).distinct(_entitiesEquals); - } - - bool _entitiesEquals(DocumentEntity? previous, DocumentEntity? next) { - final previousId = (previous?.idHi, previous?.idLo); - final nextId = (next?.idHi, next?.idLo); - - final previousVer = (previous?.verHi, previous?.verLo); - final nextVer = (next?.verHi, next?.verLo); - - return previousId == nextId && previousVer == nextVer; - } - - Expression _filterRef( - $DocumentsTable row, - DocumentRef ref, { - bool filterVersion = true, - }) { - final id = UuidHiLo.from(ref.id); - final ver = UuidHiLo.fromNullable(ref.version); - - return Expression.and([ - row.idHi.equals(id.high), - row.idLo.equals(id.low), - if (ver != null && filterVersion) ...[ - row.verHi.equals(ver.high), - row.verLo.equals(ver.low), - ], - ]); - } - - SimpleSelectStatement<$DocumentsTable, DocumentEntity> _selectRef( - DocumentRef ref, - ) { - return select(documents) - ..where((tbl) => _filterRef(tbl, ref)) - ..orderBy([ - (u) => OrderingTerm.desc(u.verHi), - ]) - ..limit(1); - } -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_local_metadata_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_local_metadata_dao.dart new file mode 100644 index 000000000000..d6eb2a1c82f9 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_local_metadata_dao.dart @@ -0,0 +1,50 @@ +import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; +import 'package:catalyst_voices_repositories/src/database/dao/documents_v2_local_metadata_dao.drift.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_local_metadata.dart'; +import 'package:drift/drift.dart'; + +abstract interface class DocumentsV2LocalMetadataDao { + /// Deletes all local metadata records from the database. + /// + /// This operation is typically used to clear all user-specific data, + /// such as 'favorite' status. + /// Returns the number of rows that were deleted. + Future deleteWhere(); + + /// Checks if a document with the given [id] is marked as a favorite. + /// + /// Returns `true` if the document is a favorite, otherwise `false`. + /// If the document is not found, it also returns `false`. + Future isFavorite(String id); +} + +@DriftAccessor( + tables: [ + DocumentsLocalMetadata, + ], +) +class DriftDocumentsV2LocalMetadataDao extends DatabaseAccessor + with $DriftDocumentsV2LocalMetadataDaoMixin + implements DocumentsV2LocalMetadataDao { + DriftDocumentsV2LocalMetadataDao(super.attachedDatabase); + + @override + Future deleteWhere() { + final query = delete(documentsLocalMetadata); + + return query.go(); + } + + @override + Future isFavorite(String id) { + final query = selectOnly(documentsLocalMetadata) + ..addColumns([documentsLocalMetadata.isFavorite]) + ..where(documentsLocalMetadata.id.equals(id)) + ..limit(1); + + return query + .map((row) => row.read(documentsLocalMetadata.isFavorite)) + .getSingleOrNull() + .then((value) => value ?? false); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/drafts_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/drafts_dao.dart deleted file mode 100644 index c476c7bb882e..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/drafts_dao.dart +++ /dev/null @@ -1,253 +0,0 @@ -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; -import 'package:catalyst_voices_repositories/src/database/dao/drafts_dao.drift.dart'; -import 'package:catalyst_voices_repositories/src/database/query/jsonb_expressions.dart'; -import 'package:catalyst_voices_repositories/src/database/table/drafts.dart'; -import 'package:catalyst_voices_repositories/src/database/table/drafts.drift.dart'; -import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:collection/collection.dart'; -import 'package:drift/drift.dart'; -import 'package:flutter/foundation.dart'; - -/// Exposes only public operation on drafts, and related, tables. -abstract interface class DraftsDao { - /// Counts drafts matching required [ref] id and optional [ref] ver. - /// - /// If ref is null it will count all drafts. - /// - /// If [ref] ver is not specified it will return count of all version - /// matching [ref] id. - Future count({DocumentRef? ref}); - - /// Clears table. - Future deleteAll(); - - /// Deletes a document draft with [ref]. - /// - /// If [ref] is null then all drafts are deleted. - Future deleteWhere({DraftRef ref}); - - /// If version is specified in [ref] returns this version or null. - /// Returns newest version with matching id or null of none found. - Future query({required DocumentRef ref}); - - /// Returns all drafts. - /// - /// Optionally matching [ref]. - Future> queryAll({ - DocumentRef? ref, - }); - - Future queryLatest({ - CatalystId? authorId, - }); - - Future> queryVersionsOfId({required String id}); - - /// Singular version of [saveAll]. Does not run in transaction. - Future save(DocumentDraftEntity draft); - - /// Inserts all drafts. On conflicts updates. - Future saveAll(Iterable drafts); - - /// Updates matching [ref] records with [content]. - /// - /// Be aware that if version is not specified all version of [ref] id - /// will be updated. - Future updateContent({ - required DraftRef ref, - required DocumentDataContent content, - }); - - /// Same as [query] but emits updates. - Stream watch({required DocumentRef ref}); - - Stream> watchAll({ - int? limit, - DocumentType? type, - CatalystId? authorId, - }); -} - -@DriftAccessor( - tables: [ - Drafts, - ], -) -class DriftDraftsDao extends DatabaseAccessor - with $DriftDraftsDaoMixin - implements DraftsDao { - DriftDraftsDao(super.attachedDatabase); - - @override - Future count({DocumentRef? ref}) { - if (ref == null) { - return drafts.count().getSingle(); - } else { - return drafts.count(where: (row) => _filterRef(row, ref)).getSingle(); - } - } - - @override - Future deleteAll() => delete(drafts).go(); - - @override - Future deleteWhere({DraftRef? ref}) async { - if (ref == null) { - await drafts.deleteAll(); - } else { - await drafts.deleteWhere((row) => _filterRef(row, ref)); - } - } - - @override - Future query({required DocumentRef ref}) { - return _selectRef(ref).get().then((value) => value.firstOrNull); - } - - @override - Future> queryAll({ - DocumentRef? ref, - }) { - final query = select(drafts); - - if (ref != null) { - query.where((tbl) => _filterRef(tbl, ref, filterVersion: false)); - } - - return query.get(); - } - - @override - Future queryLatest({ - CatalystId? authorId, - }) { - final query = select(drafts) - ..orderBy([(t) => OrderingTerm.desc(t.verHi)]) - ..limit(1); - - if (authorId != null) { - query.where((tbl) => tbl.metadata.isAuthor(authorId)); - } - - return query.getSingleOrNull(); - } - - @override - Future> queryVersionsOfId({required String id}) { - final query = select(drafts) - ..where( - (tbl) => _filterRef( - tbl, - DraftRef(id: id), - filterVersion: false, - ), - ) - ..orderBy([ - (u) => OrderingTerm.desc(u.verHi), - ]); - - return query.get(); - } - - @override - Future save(DocumentDraftEntity draft) async { - await into(drafts).insert(draft, mode: InsertMode.insertOrReplace); - } - - @override - Future saveAll(Iterable drafts) async { - await batch((batch) { - batch.insertAll( - this.drafts, - drafts, - mode: InsertMode.insertOrReplace, - ); - }); - } - - @override - Future updateContent({ - required DocumentRef ref, - required DocumentDataContent content, - }) async { - final insertable = DraftsCompanion( - content: Value(content), - title: content.title != null ? Value(content.title!) : const Value.absent(), - ); - final query = update(drafts)..where((tbl) => _filterRef(tbl, ref)); - - final updatedRows = await query.write(insertable); - - if (kDebugMode) { - debugPrint('DraftsDao: Updated[$updatedRows] $ref rows'); - } - } - - @override - Stream watch({required DocumentRef ref}) { - return _selectRef(ref).watch().map((event) => event.firstOrNull); - } - - @override - Stream> watchAll({ - int? limit, - DocumentType? type, - CatalystId? authorId, - }) { - final query = select(drafts); - - if (type != null) { - query.where((doc) => doc.type.equals(type.uuid)); - } - if (authorId != null) { - final searchId = authorId.toSignificant().toUri().toStringWithoutScheme(); - - query.where( - (doc) => CustomExpression("json_extract(metadata, '\$.authors') LIKE '%$searchId%'"), - ); - } - - query.orderBy([ - (t) => OrderingTerm( - expression: t.verHi, - mode: OrderingMode.desc, - ), - ]); - - if (limit != null) { - query.limit(limit); - } - - return query.watch(); - } - - Expression _filterRef( - $DraftsTable row, - DocumentRef ref, { - bool filterVersion = true, - }) { - final id = UuidHiLo.from(ref.id); - final ver = UuidHiLo.fromNullable(ref.version); - - return Expression.and([ - row.idHi.equals(id.high), - row.idLo.equals(id.low), - if (ver != null && filterVersion) ...[ - row.verHi.equals(ver.high), - row.verLo.equals(ver.low), - ], - ]); - } - - SimpleSelectStatement<$DraftsTable, DocumentDraftEntity> _selectRef( - DocumentRef ref, - ) { - return select(drafts) - ..where((tbl) => _filterRef(tbl, ref)) - ..orderBy([ - (u) => OrderingTerm.desc(u.verHi), - ]) - ..limit(1); - } -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/favorites_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/favorites_dao.dart deleted file mode 100644 index 5d6d1d9eda87..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/favorites_dao.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; -import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; -import 'package:catalyst_voices_repositories/src/database/dao/favorites_dao.drift.dart'; -import 'package:catalyst_voices_repositories/src/database/table/documents_favorite.dart'; -import 'package:drift/drift.dart'; - -@DriftAccessor( - tables: [ - DocumentsFavorites, - ], -) -/// Exposes only public operations on favorites documents. It saves only document id and type. -class DriftFavoritesDao extends DatabaseAccessor - with $DriftFavoritesDaoMixin - implements FavoritesDao { - DriftFavoritesDao(super.attachedDatabase); - - @override - Future deleteAll() => delete(documentsFavorites).go(); - - @override - Future deleteWhere({required String id}) async { - final idHiLo = UuidHiLo.from(id); - - final query = delete(documentsFavorites) - ..where((tbl) { - return Expression.and([ - tbl.idHi.equals(idHiLo.high), - tbl.idLo.equals(idHiLo.low), - ]); - }); - - await query.go(); - - // When marking document as fav we want to rebuild documents streams. - db.markTablesUpdated([db.documents]); - } - - @override - Future save(DocumentFavoriteEntity entity) async { - await into(documentsFavorites).insert( - entity, - mode: InsertMode.insertOrIgnore, - ); - - // When marking document as fav we want to rebuild documents streams. - db.markTablesUpdated([db.documents]); - } - - @override - Stream watch({required String id}) { - final idHiLo = UuidHiLo.from(id); - final select = selectOnly(documentsFavorites) - ..where( - Expression.and([ - documentsFavorites.idHi.equals(idHiLo.high), - documentsFavorites.idLo.equals(idHiLo.low), - ]), - ) - ..addColumns([ - documentsFavorites.isFavorite, - ]); - - return select - .map((row) => row.read(documentsFavorites.isFavorite)) - .watchSingleOrNull() - .map((isFavorite) => isFavorite ?? false); - } - - @override - Stream> watchAll({DocumentType? type}) { - final select = selectOnly(documentsFavorites) - ..addColumns([ - documentsFavorites.idHi, - documentsFavorites.idLo, - ]); - - if (type != null) { - select.where(documentsFavorites.type.equalsValue(type)); - } - - return select.map((row) { - final udHiLo = UuidHiLo( - high: row.read(documentsFavorites.idHi)!, - low: row.read(documentsFavorites.idLo)!, - ); - return udHiLo.uuid; - }).watch(); - } -} - -abstract interface class FavoritesDao { - Future deleteAll(); - - Future deleteWhere({required String id}); - - Future save(DocumentFavoriteEntity entity); - - Stream watch({required String id}); - - Stream> watchAll({DocumentType? type}); -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/proposals_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/proposals_dao.dart deleted file mode 100644 index 85dc8c3b41dc..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/proposals_dao.dart +++ /dev/null @@ -1,836 +0,0 @@ -import 'dart:async'; - -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; -import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; -import 'package:catalyst_voices_repositories/src/database/dao/proposals_dao.drift.dart'; -import 'package:catalyst_voices_repositories/src/database/query/jsonb_expressions.dart'; -import 'package:catalyst_voices_repositories/src/database/table/documents.dart'; -import 'package:catalyst_voices_repositories/src/database/table/documents.drift.dart' - show $DocumentsTable; -import 'package:catalyst_voices_repositories/src/database/table/documents_favorite.dart'; -import 'package:catalyst_voices_repositories/src/dto/proposal/proposal_submission_action_dto.dart'; -import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:collection/collection.dart'; -import 'package:drift/drift.dart'; -import 'package:drift/extensions/json1.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart'; -import 'package:rxdart/rxdart.dart'; - -/// Exposes only public operation on proposals, and related tables. -/// This is a wrapper around [DocumentsDao] and [DraftsDao] to provide a single interface for proposals. -/// Since proposals are composed of multiple documents (template, action, comments, etc.) we need to -/// join multiple tables to get all the information about a proposal, which make sense to create this specialized dao. -@DriftAccessor( - tables: [ - Documents, - DocumentsMetadata, - DocumentsFavorites, - ], -) -class DriftProposalsDao extends DatabaseAccessor - with $DriftProposalsDaoMixin - implements ProposalsDao { - DriftProposalsDao(super.attachedDatabase); - - // TODO(dt-iohk): it seems that this method doesn't correctly filter by ProposalsFilterType.my - // since it does not check for author, consider to use another type which doesn't have "my" case. - - // TODO(damian-molinski): filters is only used for campaign and type. - @override - Future> queryProposals({ - SignedDocumentRef? categoryRef, - required ProposalsFilters filters, - }) async { - if ([ - filters.author, - filters.onlyAuthor, - filters.category, - filters.searchQuery, - filters.maxAge, - ].nonNulls.isNotEmpty) { - if (kDebugMode) { - print('queryProposals supports only campaign and type filters'); - } - } - - final latestProposalRef = alias(documents, 'latestProposalRef'); - final proposal = alias(documents, 'proposal'); - - final maxVerHi = latestProposalRef.verHi.max(); - final latestProposalsQuery = selectOnly(latestProposalRef, distinct: true) - ..addColumns([ - latestProposalRef.idHi, - latestProposalRef.idLo, - maxVerHi, - latestProposalRef.verLo, - ]) - ..where(latestProposalRef.type.equalsValue(DocumentType.proposalDocument)) - ..groupBy([latestProposalRef.idHi + latestProposalRef.idLo]); - - final verSubquery = Subquery(latestProposalsQuery, 'latestProposalRef'); - - final mainQuery = - select(proposal).join([ - innerJoin( - verSubquery, - Expression.and([ - verSubquery.ref(maxVerHi).equalsExp(proposal.verHi), - verSubquery.ref(latestProposalRef.verLo).equalsExp(proposal.verLo), - ]), - useColumns: false, - ), - ]) - ..where( - Expression.and([ - proposal.type.equalsValue(DocumentType.proposalDocument), - proposal.metadata.jsonExtract(r'$.template').isNotNull(), - proposal.metadata.jsonExtract(r'$.categoryId.id').isNotNull(), - if (filters.campaign != null) - proposal.metadata - .jsonExtract(r'$.categoryId.id') - .isIn(filters.campaign!.categoriesIds), - ]), - ) - ..orderBy([OrderingTerm.asc(proposal.verHi)]); - - if (categoryRef != null) { - mainQuery.where(proposal.metadata.isCategory(categoryRef)); - } - - final ids = await _getFilterTypeIds(filters.type); - - final include = ids.include; - if (include != null) { - final highs = include.map((e) => e.high); - final lows = include.map((e) => e.low); - mainQuery.where( - Expression.and([ - proposal.idHi.isIn(highs), - proposal.idLo.isIn(lows), - ]), - ); - } - - final exclude = ids.exclude; - if (exclude != null) { - final highs = exclude.map((e) => e.high); - final lows = exclude.map((e) => e.low); - - mainQuery.where( - Expression.and([ - proposal.idHi.isNotIn(highs), - proposal.idLo.isNotIn(lows), - ]), - ); - } - - final proposals = await mainQuery - .map((row) => row.readTable(proposal)) - .get() - .then((entities) => entities.map(_buildJoinedProposal).toList().wait); - - return proposals; - } - - @override - Future> queryProposalsPage({ - required PageRequest request, - required ProposalsFilters filters, - required ProposalsOrder order, - }) async { - final author = filters.author; - final searchQuery = filters.searchQuery; - - final latestProposalRef = alias(documents, 'latestProposalRef'); - final proposal = alias(documents, 'proposal'); - - final maxVerHi = latestProposalRef.verHi.max(); - final latestProposalsQuery = selectOnly(latestProposalRef, distinct: true) - ..addColumns([ - latestProposalRef.idHi, - latestProposalRef.idLo, - maxVerHi, - latestProposalRef.verLo, - ]) - ..where(latestProposalRef.type.equalsValue(DocumentType.proposalDocument)) - ..groupBy([latestProposalRef.idHi + latestProposalRef.idLo]); - - final verSubquery = Subquery(latestProposalsQuery, 'latestProposalRef'); - - final mainQuery = - select(proposal).join([ - innerJoin( - verSubquery, - Expression.and([ - verSubquery.ref(maxVerHi).equalsExp(proposal.verHi), - verSubquery.ref(latestProposalRef.verLo).equalsExp(proposal.verLo), - ]), - useColumns: false, - ), - ]) - ..where( - Expression.and([ - proposal.type.equalsValue(DocumentType.proposalDocument), - // Safe check for invalid proposals - proposal.metadata.jsonExtract(r'$.template').isNotNull(), - proposal.metadata.jsonExtract(r'$.categoryId').isNotNull(), - if (filters.campaign != null) - proposal.metadata - .jsonExtract(r'$.categoryId.id') - .isIn(filters.campaign!.categoriesIds), - ]), - ) - ..orderBy(order.terms(proposal)) - ..limit(request.size, offset: request.page * request.size); - - final ids = await _getFilterTypeIds(filters.type); - - final include = ids.include; - if (include != null) { - final highs = include.map((e) => e.high); - final lows = include.map((e) => e.low); - mainQuery.where( - Expression.and([ - proposal.idHi.isIn(highs), - proposal.idLo.isIn(lows), - ]), - ); - } - - final exclude = ids.exclude; - if (exclude != null) { - final highs = exclude.map((e) => e.high); - final lows = exclude.map((e) => e.low); - - mainQuery.where( - Expression.and([ - proposal.idHi.isNotIn(highs), - proposal.idLo.isNotIn(lows), - ]), - ); - } - - if ((filters.onlyAuthor ?? false) || filters.type.isMy) { - if (author != null) { - mainQuery.where(proposal.metadata.isAuthor(author)); - } else { - return Page( - page: request.page, - maxPerPage: request.size, - total: 0, - items: List.empty(), - ); - } - } - - if (filters.category != null) { - mainQuery.where(proposal.metadata.isCategory(filters.category!)); - } - - if (searchQuery != null) { - // TODO(damian-molinski): Check if documentsMetadata can be used. - mainQuery.where(proposal.search(searchQuery)); - } - - final maxAge = filters.maxAge; - if (maxAge != null) { - final now = DateTimeExt.now(utc: true); - final oldestDateTime = now.subtract(maxAge); - final uuid = UuidUtils.buildV7At(oldestDateTime); - final hiLo = UuidHiLo.from(uuid); - mainQuery.where(proposal.verHi.isBiggerThanValue(hiLo.high)); - } - - final proposals = await mainQuery - .map((row) => row.readTable(proposal)) - .get() - .then((entities) => entities.map(_buildJoinedProposal).wait); - - final total = await watchCount( - filters: filters.toCountFilters(), - ).first.then((count) => count.ofType(filters.type)); - - return Page( - page: request.page, - maxPerPage: request.size, - total: total, - items: proposals, - ); - } - - @override - Stream watchCount({ - required ProposalsCountFilters filters, - }) { - final stream = _getProposalsRefsStream(filters: filters); - - return _transformRefsStreamToCount(stream, author: filters.author); - } - - @override - Stream> watchProposalsPage({ - required PageRequest request, - required ProposalsFilters filters, - required ProposalsOrder order, - }) async* { - yield await queryProposalsPage(request: request, filters: filters, order: order); - - yield* connection.streamQueries - .updatesForSync(TableUpdateQuery.onAllTables([documents, documentsFavorites])) - .debounceTime(const Duration(milliseconds: 10)) - .asyncMap((event) { - return queryProposalsPage(request: request, filters: filters, order: order); - }); - } - - // TODO(damian-molinski): Make this more specialized per case. - // for example proposals list does not need all versions, just count. - Future _buildJoinedProposal( - DocumentEntity proposal, - ) async { - assert( - proposal.type == DocumentType.proposalDocument, - 'Invalid document type', - ); - - var proposalRef = proposal.metadata.selfRef; - - final latestAction = await _getProposalsLatestAction( - proposalId: proposalRef.id, - ).then((value) => value.singleOrNull); - - var effectiveProposal = proposal; - - if (latestAction != null && latestAction.proposalRef != proposalRef) { - final latestActionProposal = await _getDocument(latestAction.proposalRef); - - effectiveProposal = latestActionProposal; - } - - proposalRef = effectiveProposal.metadata.selfRef; - - final templateFuture = _getDocument(effectiveProposal.metadata.template!); - final actionFuture = _maybeGetDocument(latestAction?.selfRef); - final commentsCountFuture = _getProposalCommentsCount(proposalRef); - final versionsFuture = _getProposalVersions(proposalRef.id); - - final (template, action, commentsCount, versions) = await ( - templateFuture, - actionFuture, - commentsCountFuture, - versionsFuture, - ).wait; - - return JoinedProposalEntity( - proposal: effectiveProposal, - template: template, - action: action, - commentsCount: commentsCount, - versions: versions, - ); - } - - Future<_IdsFilter> _excludeHiddenProposalsFilter() { - return _getProposalsLatestAction() - .then((value) { - return value - .where((e) => e.action.isHidden) - .map((e) => e.proposalRef.id) - .map(UuidHiLo.from); - }) - .then(_IdsFilter.exclude); - } - - Future<_IdsFilter> _excludeNotDraftProposalsFilter() { - return _getProposalsLatestAction() - .then( - (value) { - return value - .where((element) => !element.action.isDraft) - .map((e) => e.proposalRef.id) - .map(UuidHiLo.from); - }, - ) - .then(_IdsFilter.exclude); - } - - Future> _getAuthorProposalsLooseRefs({ - required CatalystId author, - }) { - final query = selectOnly(documents) - ..addColumns([ - documents.idHi, - documents.idLo, - ]) - ..where( - Expression.and([ - documents.type.equalsValue(DocumentType.proposalDocument), - documents.metadata.isAuthor(author), - ]), - ); - - return query.map((row) { - final id = UuidHiLo( - high: row.read(documents.idHi)!, - low: row.read(documents.idLo)!, - ); - - return SignedDocumentRef.loose(id: id.uuid); - }).get(); - } - - Future _getDocument(DocumentRef ref) async { - final document = await _maybeGetDocument(ref); - assert(document != null, 'Did not found document with ref[$ref]'); - return document!; - } - - Future> _getFavoritesRefs() { - final query = selectOnly(documentsFavorites) - ..addColumns([ - documentsFavorites.idHi, - documentsFavorites.idLo, - ]) - ..where( - Expression.and([ - documentsFavorites.type.equalsValue(DocumentType.proposalDocument), - documentsFavorites.isFavorite.equals(true), - ]), - ); - - return query.map((row) { - final id = UuidHiLo( - high: row.read(documentsFavorites.idHi)!, - low: row.read(documentsFavorites.idLo)!, - ); - - return SignedDocumentRef.loose(id: id.uuid); - }).get(); - } - - Future<_IdsFilter> _getFilterTypeIds(ProposalsFilterType type) { - switch (type) { - case ProposalsFilterType.total: - return _excludeHiddenProposalsFilter(); - case ProposalsFilterType.drafts: - return _excludeNotDraftProposalsFilter(); - case ProposalsFilterType.finals: - return _includeFinalProposalsFilter(); - case ProposalsFilterType.favorites: - return _includeFavoriteRefsExcludingHiddenProposalsFilter(); - case ProposalsFilterType.favoritesFinals: - return _includeFinalFavoriteRefsProposalsFilter(); - case ProposalsFilterType.my: - return _excludeHiddenProposalsFilter(); - case ProposalsFilterType.myFinals: - return _includeFinalProposalsFilter(); - case ProposalsFilterType.voted: - return _includeVotedRefsExcludingHiddenProposalsFilter(); - } - } - - Future _getProposalCommentsCount(DocumentRef ref) { - final id = ref.id; - final ver = ref.version; - - final amountOfComment = documents.rowId.count(); - - final query = selectOnly(documents) - ..addColumns([ - amountOfComment, - ]) - ..where( - Expression.and([ - documents.type.equalsValue(DocumentType.commentDocument), - documents.metadata.jsonExtract(r'$.ref.id').equals(id), - if (ver != null) documents.metadata.jsonExtract(r'$.ref.version').equals(ver), - ]), - ) - ..orderBy([OrderingTerm.desc(documents.verHi)]); - - return query - .map((row) => row.read(amountOfComment)) - .getSingleOrNull() - .then((value) => value ?? 0); - } - - Future> _getProposalsLatestAction({ - String? proposalId, - }) { - final refId = documents.metadata.jsonExtract(r'$.ref.id'); - final refVer = documents.metadata.jsonExtract(r'$.ref.version'); - final query = selectOnly(documents, distinct: true) - ..addColumns([ - documents.idHi, - documents.idLo, - documents.verHi, - documents.verLo, - refId, - refVer, - documents.content, - ]) - ..where( - Expression.and([ - documents.type.equalsValue(DocumentType.proposalActionDocument), - refId.isNotNull(), - if (proposalId != null) refId.equals(proposalId), - ]), - ) - ..orderBy([OrderingTerm.desc(documents.verHi)]); - - return query - .map((row) { - final selfRef = row.readSelfRef(documents); - final proposalRef = SignedDocumentRef( - id: row.read(refId)!, - version: row.read(refVer), - ); - - final content = row.readWithConverter(documents.content); - final rawAction = content?.data['action']; - final actionDto = rawAction is String - ? ProposalSubmissionActionDto.fromJson(rawAction) - : null; - final action = actionDto?.toModel(); - - return _ProposalActions( - selfRef: selfRef, - proposalRef: proposalRef, - action: action ?? ProposalSubmissionAction.draft, - ); - }) - .get() - .then((entities) { - final grouped = {}; - - for (final entry in entities.nonNulls) { - // 1st element per ref is latest. See orderBy. - final id = entry.proposalRef.id; - if (!grouped.containsKey(id)) { - grouped[id] = entry; - } - } - - return grouped.values.toList(); - }); - } - - Stream> _getProposalsRefsStream({ - ProposalsCountFilters? filters, - }) { - final author = filters?.author; - final searchQuery = filters?.searchQuery; - - final query = selectOnly(documents) - ..addColumns([ - documents.idHi, - documents.idLo, - documents.verHi, - documents.verLo, - ]) - ..where( - Expression.and([ - documents.type.equalsValue(DocumentType.proposalDocument), - // Safe check for invalid proposals - documents.metadata.jsonExtract(r'$.template').isNotNull(), - documents.metadata.jsonExtract(r'$.categoryId').isNotNull(), - if (filters?.campaign != null) - documents.metadata - .jsonExtract(r'$.categoryId.id') - .isIn(filters!.campaign!.categoriesIds), - ]), - ) - ..orderBy([OrderingTerm.desc(documents.verHi)]) - ..groupBy([documents.idHi + documents.idLo]); - - if ((filters?.onlyAuthor ?? false) && author != null) { - query.where(documents.metadata.isAuthor(author)); - } - - if (filters?.category != null) { - query.where(documents.metadata.isCategory(filters!.category!)); - } - - if (searchQuery != null) { - // TODO(damian-molinski): Check if documentsMetadata can be used. - query.where(documents.search(searchQuery)); - } - - final maxAge = filters?.maxAge; - if (maxAge != null) { - final now = DateTimeExt.now(utc: true); - final oldestDateTime = now.subtract(maxAge); - final uuid = UuidUtils.buildV7At(oldestDateTime); - final hiLo = UuidHiLo.from(uuid); - query.where(documents.verHi.isBiggerThanValue(hiLo.high)); - } - - return query.map((row) => row.readSelfRef(documents)).watch(); - } - - Future> _getProposalVersions(String id) { - final idHiLo = UuidHiLo.from(id); - - final query = selectOnly(documents) - ..addColumns([ - documents.verHi, - documents.verLo, - ]) - ..where( - Expression.and([ - documents.idHi.equals(idHiLo.high), - documents.idLo.equals(idHiLo.low), - ]), - ) - ..orderBy([OrderingTerm.desc(documents.verHi)]); - - return query.map((row) { - final high = row.read(documents.verHi)!; - final low = row.read(documents.verLo)!; - return UuidHiLo(high: high, low: low).uuid; - }).get(); - } - - Future> _getVotedRefs() async { - // TODO(dt-iohk): query refs of voted proposals - return []; - } - - Future<_IdsFilter> _includeFavoriteRefsExcludingHiddenProposalsFilter() { - return _getFavoritesRefs() - .then((favoriteRefs) { - return _getProposalsLatestAction().then((actions) async { - final hiddenProposalsIds = actions - .where((e) => e.action.isHidden) - .map((e) => e.proposalRef.id); - - return favoriteRefs - .map((e) => e.id) - .whereNot(hiddenProposalsIds.contains) - .map(UuidHiLo.from); - }); - }) - .then(_IdsFilter.include); - } - - Future<_IdsFilter> _includeFinalFavoriteRefsProposalsFilter() { - return _getFavoritesRefs() - .then((favoriteRefs) { - return _getProposalsLatestAction().then((actions) async { - final finalProposalIds = actions - .where((e) => e.action.isFinal) - .map((e) => e.proposalRef.id); - - return favoriteRefs - .map((e) => e.id) - .where(finalProposalIds.contains) - .map(UuidHiLo.from); - }); - }) - .then(_IdsFilter.include); - } - - Future<_IdsFilter> _includeFinalProposalsFilter() { - return _getProposalsLatestAction() - .then( - (value) { - return value - .where((element) => element.action.isFinal) - .map((e) => e.proposalRef.id) - .map(UuidHiLo.from); - }, - ) - .then(_IdsFilter.include); - } - - Future<_IdsFilter> _includeVotedRefsExcludingHiddenProposalsFilter() { - return _getVotedRefs() - .then((votedRefs) { - return _getProposalsLatestAction().then((actions) async { - final hiddenProposalsIds = actions - .where((e) => e.action.isHidden) - .map((e) => e.proposalRef.id); - - return votedRefs - .map((e) => e.id) - .whereNot(hiddenProposalsIds.contains) - .map(UuidHiLo.from); - }); - }) - .then(_IdsFilter.include); - } - - Future> _maybeGetAuthorProposalsLooseRefs({ - CatalystId? author, - }) { - if (author == null) { - return Future(List.empty); - } - - return _getAuthorProposalsLooseRefs(author: author); - } - - Future _maybeGetDocument(DocumentRef? ref) { - if (ref == null) { - return Future.value(); - } - - final id = UuidHiLo.from(ref.id); - final ver = UuidHiLo.fromNullable(ref.version); - - final query = select(documents) - ..where( - (tbl) => Expression.and([ - tbl.idHi.equals(id.high), - tbl.idLo.equals(id.low), - if (ver != null) ...[ - tbl.verHi.equals(ver.high), - tbl.verLo.equals(ver.low), - ], - ]), - ) - ..limit(1); - - return query.getSingleOrNull(); - } - - Stream _transformRefsStreamToCount( - Stream> source, { - CatalystId? author, - }) { - return source.asyncMap((allRefs) async { - final latestActions = await _getProposalsLatestAction(); - final hiddenRefs = latestActions - .where((element) => element.action.isHidden) - .map((e) => e.proposalRef); - final finalsRefs = latestActions - .where((element) => element.action.isFinal) - .map((e) => e.proposalRef); - - final favoritesRefs = await _getFavoritesRefs(); - final votedRefs = await _getVotedRefs(); - final myRefs = await _maybeGetAuthorProposalsLooseRefs(author: author); - - final notHidden = allRefs.where((ref) => hiddenRefs.none((myRef) => myRef.id == ref.id)); - - final total = notHidden; - final finals = notHidden.where((ref) => finalsRefs.any((myRef) => myRef.id == ref.id)); - final favorites = notHidden.where((ref) => favoritesRefs.any((fav) => fav.id == ref.id)); - final favoritesFinals = finals.where((ref) => favoritesRefs.any((fav) => fav.id == ref.id)); - final my = notHidden.where((ref) => myRefs.any((myRef) => myRef.id == ref.id)); - final myFinals = my.where((ref) => finalsRefs.any((myRef) => myRef.id == ref.id)); - final votedOn = notHidden.where((ref) => votedRefs.any((voted) => voted.id == ref.id)); - - return ProposalsCount( - total: total.length, - drafts: total.length - finals.length, - finals: finals.length, - favorites: favorites.length, - favoritesFinals: favoritesFinals.length, - my: my.length, - myFinals: myFinals.length, - voted: votedOn.length, - ); - }); - } -} - -abstract interface class ProposalsDao { - Future> queryProposals({ - SignedDocumentRef? categoryRef, - required ProposalsFilters filters, - }); - - Future> queryProposalsPage({ - required PageRequest request, - required ProposalsFilters filters, - required ProposalsOrder order, - }); - - Stream watchCount({ - required ProposalsCountFilters filters, - }); - - Stream> watchProposalsPage({ - required PageRequest request, - required ProposalsFilters filters, - required ProposalsOrder order, - }); -} - -final class _IdsFilter extends Equatable { - final Iterable? include; - final Iterable? exclude; - - // ignore: unused_element_parameter - const _IdsFilter({this.include, this.exclude}); - - const _IdsFilter.exclude(this.exclude) : include = null; - - const _IdsFilter.include(this.include) : exclude = null; - - @override - List get props => [include, exclude]; -} - -final class _ProposalActions extends Equatable { - final SignedDocumentRef selfRef; - final SignedDocumentRef proposalRef; - final ProposalSubmissionAction action; - - const _ProposalActions({ - required this.selfRef, - required this.proposalRef, - required this.action, - }); - - @override - List get props => [selfRef, proposalRef, action]; -} - -extension on ProposalSubmissionAction { - bool get isDraft => this == ProposalSubmissionAction.draft; - - bool get isFinal => this == ProposalSubmissionAction.aFinal; - - bool get isHidden => this == ProposalSubmissionAction.hide; -} - -extension on TypedResult { - SignedDocumentRef readSelfRef($DocumentsTable table) { - final idHiLo = (read(table.idHi)!, read(table.idLo)!); - final verHiLo = (read(table.verHi)!, read(table.verLo)!); - - final id = UuidHiLo(high: idHiLo.$1, low: idHiLo.$2).uuid; - final ver = UuidHiLo(high: verHiLo.$1, low: verHiLo.$2).uuid; - - return SignedDocumentRef(id: id, version: ver); - } -} - -extension on ProposalsOrder { - List terms($DocumentsTable table) { - return switch (this) { - Alphabetical() => [ - OrderingTerm.asc(table.content.title.collate(Collate.noCase), nulls: NullsOrder.last), - OrderingTerm.desc(table.verHi), - ], - Budget(:final isAscending) => [ - OrderingTerm( - expression: table.content.requestedFunds, - mode: isAscending ? OrderingMode.asc : OrderingMode.desc, - nulls: NullsOrder.last, - ), - OrderingTerm.desc(table.verHi), - ], - UpdateDate(:final isAscending) => [ - OrderingTerm( - expression: table.verHi, - mode: isAscending ? OrderingMode.asc : OrderingMode.desc, - ), - ], - }; - } -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/workspace_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/workspace_dao.dart new file mode 100644 index 000000000000..895122ed905b --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/workspace_dao.dart @@ -0,0 +1,32 @@ +//ignore_for_file: one_member_abstracts + +import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; +import 'package:catalyst_voices_repositories/src/database/dao/workspace_dao.drift.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_local_metadata.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_v2.dart'; +import 'package:catalyst_voices_repositories/src/database/table/local_documents_drafts.dart'; +import 'package:drift/drift.dart'; + +@DriftAccessor( + tables: [ + DocumentsV2, + LocalDocumentsDrafts, + DocumentsLocalMetadata, + ], +) +class DriftWorkspaceDao extends DatabaseAccessor + with $DriftWorkspaceDaoMixin + implements WorkspaceDao { + DriftWorkspaceDao(super.attachedDatabase); + + @override + Future deleteLocalDrafts() { + final query = delete(localDocumentsDrafts); + + return query.go(); + } +} + +abstract interface class WorkspaceDao { + Future deleteLocalDrafts(); +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/database.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/database.dart index f0648e320a42..361d39b67b70 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/database.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/database.dart @@ -1,13 +1,6 @@ export 'catalyst_database.dart' show CatalystDatabase; export 'catalyst_database_config.dart'; -export 'dao/documents_dao.dart' show DocumentsDao; -export 'dao/drafts_dao.dart' show DraftsDao; -export 'dao/favorites_dao.dart' show FavoritesDao; -export 'dao/proposals_dao.dart' show ProposalsDao; -export 'model/joined_proposal_entity.dart'; -export 'table/documents.drift.dart' show DocumentEntity; -export 'table/documents_favorite.drift.dart' show DocumentFavoriteEntity; -export 'table/documents_metadata.dart'; -export 'table/documents_metadata.drift.dart' show DocumentMetadataEntity; -export 'table/drafts.drift.dart' show DocumentDraftEntity; -export 'typedefs.dart'; +export 'dao/documents_v2_dao.dart' show DocumentsV2Dao; +export 'dao/documents_v2_local_metadata_dao.dart' show DocumentsV2LocalMetadataDao; +export 'dao/proposals_v2_dao.dart' show ProposalsV2Dao; +export 'dao/workspace_dao.dart' show WorkspaceDao; diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/model/joined_proposal_entity.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/model/joined_proposal_entity.dart deleted file mode 100644 index 64e17f3c2455..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/model/joined_proposal_entity.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; -import 'package:equatable/equatable.dart'; - -/// Specialized entity that represents a proposal. -/// -/// It is a result of joining multiple tables to get all the information about a proposal. -final class JoinedProposalEntity extends Equatable { - final DocumentEntity proposal; - final DocumentEntity template; - final DocumentEntity? action; - final int commentsCount; - final List versions; - - const JoinedProposalEntity({ - required this.proposal, - required this.template, - this.action, - this.commentsCount = 0, - this.versions = const [], - }); - - @override - List get props => [ - proposal, - template, - action, - commentsCount, - versions, - ]; -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/query/jsonb_expressions.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/query/jsonb_expressions.dart deleted file mode 100644 index 8365e339f5e4..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/query/jsonb_expressions.dart +++ /dev/null @@ -1,200 +0,0 @@ -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_repositories/src/database/table/documents.dart' show Documents; -import 'package:catalyst_voices_repositories/src/database/table/documents.drift.dart'; -import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:drift/drift.dart'; -import 'package:drift/extensions/json1.dart'; - -class BaseJsonQueryExpression extends Expression { - final String jsonContent; - final NodeId nodeId; - final String searchValue; - final bool useExactMatch; - - const BaseJsonQueryExpression({ - required this.jsonContent, - required this.nodeId, - required this.searchValue, - this.useExactMatch = false, - }); - - @override - void writeInto(GenerationContext context) { - final sql = JsonBExpressions.generateSqlForJsonQuery( - jsonContent: jsonContent, - nodeId: nodeId, - searchValue: searchValue, - ); - - context.buffer.write(sql); - } -} - -final class ContainsAuthorId extends BaseJsonQueryExpression { - ContainsAuthorId({ - required CatalystId id, - }) : super( - searchValue: id.toSignificant().toUri().toStringWithoutScheme(), - nodeId: ProposalMetadata.authorsNode, - jsonContent: 'metadata', - ); -} - -final class ContainsContentAuthorName extends BaseJsonQueryExpression { - ContainsContentAuthorName({ - required String query, - }) : super( - searchValue: query, - nodeId: ProposalDocument.authorNameNodeId, - jsonContent: 'content', - ); -} - -final class ContainsMetadataAuthorName extends BaseJsonQueryExpression { - ContainsMetadataAuthorName({ - required String query, - }) : super( - searchValue: query, - nodeId: ProposalMetadata.authorsNode, - jsonContent: 'metadata', - ); -} - -final class ContainsTitle extends BaseJsonQueryExpression { - ContainsTitle({ - required String query, - }) : super( - searchValue: query, - nodeId: ProposalDocument.titleNodeId, - jsonContent: 'content', - ); -} - -class JsonBExpressions { - const JsonBExpressions(); - - static String generateSqlForJsonQuery({ - required String jsonContent, - required NodeId nodeId, - required String searchValue, - bool useExactMatch = false, - }) { - final valueComparison = useExactMatch ? "= '$searchValue'" : "LIKE '%$searchValue%'"; - final handler = WildcardPathHandler.fromNodeId(nodeId); - final wildcardPaths = handler.getWildcardPaths; - - if (!handler.hasWildcard || wildcardPaths == null) { - return _queryJsonExtract( - jsonContent: jsonContent, - nodeId: nodeId, - valueComparison: valueComparison, - ); - } - - final arrayPath = wildcardPaths.prefix.value.isEmpty ? '' : wildcardPaths.prefix.asPath; - final fieldName = wildcardPaths.suffix?.asPath; - - if (wildcardPaths.prefix.value.isEmpty) { - return _queryJsonTreeForKey( - jsonContent: jsonContent, - fieldName: fieldName?.substring(2), - valueComparison: valueComparison, - ); - } - - if (fieldName != null) { - return _queryJsonEachForWildcard( - jsonContent: jsonContent, - arrayPath: arrayPath, - fieldName: fieldName, - valueComparison: valueComparison, - ); - } - - return _queryJsonTreeForWildcard( - jsonContent: jsonContent, - arrayPath: arrayPath, - valueComparison: valueComparison, - ); - } - - static String _queryJsonEachForWildcard({ - required String jsonContent, - required String arrayPath, - required String fieldName, - required String valueComparison, - }) { - return "EXISTS (SELECT 1 FROM json_each(json_extract($jsonContent, '$arrayPath')) WHERE json_extract(value, '$fieldName') $valueComparison)"; - } - - static String _queryJsonExtract({ - required String jsonContent, - required NodeId nodeId, - required String valueComparison, - }) { - return "json_extract($jsonContent, '${nodeId.asPath}') $valueComparison"; - } - - static String _queryJsonTreeForKey({ - required String jsonContent, - required String? fieldName, - required String valueComparison, - }) { - return "EXISTS (SELECT 1 FROM json_tree($jsonContent) WHERE key LIKE '$fieldName' AND json_tree.value $valueComparison)"; - } - - static String _queryJsonTreeForWildcard({ - required String jsonContent, - required String arrayPath, - required String valueComparison, - }) { - return "EXISTS (SELECT 1 FROM json_tree($jsonContent, '$arrayPath') WHERE json_tree.value $valueComparison)"; - } -} - -extension on NodeId { - /// Converts [NodeId] into jsonb well-formatted path argument. - /// - /// This relies on fact that [NodeId] (especially [DocumentNodeId]) is already following - /// convention of separating paths with '.' (dots). - /// - /// Read more: https://sqlite.org/json1.html#path_arguments. - String get asPath => '\$.$value'; -} - -/// Extension allowing extraction of commonly used values from [DocumentDataContent] in a type-safe way. -extension ContentColumnExt on GeneratedColumnWithTypeConverter { - Expression get requestedFunds => jsonExtract(ProposalDocument.requestedFundsNodeId.asPath); - - Expression get title => jsonExtract(ProposalDocument.titleNodeId.asPath); - - Expression hasTitle(String query) => ContainsTitle(query: query); -} - -/// Extension allowing extraction of commonly used values from [Documents] table in a type-safe way. -extension DocumentTableExt on $DocumentsTable { - Expression search(String query) { - return Expression.or( - [ - ...metadata.hasAuthorName(query), - content.hasTitle(query), - ], - ); - } -} - -/// Extension allowing extraction of commonly used values from [DocumentDataMetadata] in a type-safe way. -extension MetadataColumnExt on GeneratedColumnWithTypeConverter { - List> hasAuthorName(String name) { - return [ - ContainsMetadataAuthorName(query: name), - ContainsContentAuthorName(query: name), - ]; - } - - Expression isAuthor(CatalystId id) => ContainsAuthorId(id: id); - - Expression isCategory(SignedDocumentRef ref) { - return jsonExtract(ProposalMetadata.categoryIdNode.asPath).equals(ref.id); - } -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/converter/document_converters.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/converter/document_converters.dart index 40bf6592721b..f445781b99d4 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/converter/document_converters.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/converter/document_converters.dart @@ -1,12 +1,8 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_repositories/src/dto/document/document_data_dto.dart'; import 'package:drift/drift.dart'; typedef DocumentContentJsonBConverter = JsonTypeConverter2; -typedef DocumentMetadataJsonBConverter = - JsonTypeConverter2; - abstract final class DocumentConverters { /// Converts [DocumentType] to String for text column. static const TypeConverter type = _DocumentTypeConverter(); @@ -17,13 +13,6 @@ abstract final class DocumentConverters { fromJson: (json) => DocumentDataContent(json! as Map), toJson: (content) => content.data, ); - - /// Converts [DocumentDataMetadata] into json for bloc column. - /// Required for jsonb queries. - static final DocumentMetadataJsonBConverter metadata = TypeConverter.jsonb( - fromJson: (json) => DocumentDataMetadataDto.fromJson(json! as Map).toModel(), - toJson: (metadata) => DocumentDataMetadataDto.fromModel(metadata).toJson(), - ); } final class _DocumentTypeConverter extends TypeConverter { diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents.dart deleted file mode 100644 index 0917d6089c1e..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_repositories/src/database/table/mixin/document_table_mixin.dart'; -import 'package:catalyst_voices_repositories/src/database/table/mixin/id_table_mixin.dart'; -import 'package:catalyst_voices_repositories/src/database/table/mixin/ver_table_mixin.dart'; -import 'package:drift/drift.dart'; - -/// This table stores a record of each document (including its content and -/// related metadata). -/// -/// Its representation of [DocumentData] class. -@TableIndex(name: 'idx_doc_type', columns: {#type}) -@TableIndex(name: 'idx_unique_ver', columns: {#verHi, #verLo}, unique: true) -@DataClassName('DocumentEntity') -class Documents extends Table with IdHiLoTableMixin, VerHiLoTableMixin, DocumentTableMixin { - DateTimeColumn get createdAt => dateTime()(); - - @override - Set>? get primaryKey => { - idHi, - idLo, - verHi, - verLo, - }; -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents_favorite.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents_favorite.dart deleted file mode 100644 index 775c38483600..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents_favorite.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:catalyst_voices_repositories/src/database/table/converter/document_converters.dart'; -import 'package:catalyst_voices_repositories/src/database/table/mixin/id_table_mixin.dart'; -import 'package:drift/drift.dart'; - -@TableIndex(name: 'idx_fav_type', columns: {#type}) -@TableIndex(name: 'idx_fav_unique_id', columns: {#idHi, #idLo}, unique: true) -@DataClassName('DocumentFavoriteEntity') -class DocumentsFavorites extends Table with IdHiLoTableMixin { - BoolColumn get isFavorite => boolean()(); - - @override - Set get primaryKey => { - idHi, - idLo, - }; - - TextColumn get type => text().map(DocumentConverters.type)(); -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents_metadata.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents_metadata.dart deleted file mode 100644 index 1b32814ce256..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents_metadata.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:catalyst_voices_repositories/src/database/table/documents.drift.dart'; -import 'package:catalyst_voices_repositories/src/database/table/mixin/ver_table_mixin.dart'; -import 'package:drift/drift.dart'; - -/// Syntax sugar num to working with [DocumentsMetadata] queries -/// so keys names are strongly defined here. -/// -/// Add new fields as needed. -enum DocumentMetadataFieldKey { - title, -} - -/// This table breaks out metadata into a key-value structure for each -/// document version, enabling more granular or indexed queries -@TableIndex( - name: 'idx_doc_metadata_key_value', - columns: {#fieldKey, #fieldValue}, -) -@DataClassName('DocumentMetadataEntity') -class DocumentsMetadata extends Table with VerHiLoTableMixin { - @override - List get customConstraints => [ - /// Referring with two columns throws a - /// "SqliteException(1): foreign key mismatch" - /// But when doing it explicitly it no longer complains - 'FOREIGN KEY("ver_hi", "ver_lo") REFERENCES "${$DocumentsTable.$name}"("ver_hi", "ver_lo") ON DELETE CASCADE ON UPDATE CASCADE', - ]; - - /// e.g. 'category', 'title', 'description' - TextColumn get fieldKey => textEnum()(); - - /// The actual value (for category, title, description, etc.) - TextColumn get fieldValue => text()(); - - @override - Set get primaryKey => { - verHi, - verLo, - fieldKey, - }; -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/drafts.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/drafts.dart deleted file mode 100644 index 7164fed83336..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/drafts.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:catalyst_voices_repositories/src/database/table/documents_metadata.dart'; -import 'package:catalyst_voices_repositories/src/database/table/mixin/document_table_mixin.dart'; -import 'package:catalyst_voices_repositories/src/database/table/mixin/id_table_mixin.dart'; -import 'package:catalyst_voices_repositories/src/database/table/mixin/ver_table_mixin.dart'; -import 'package:drift/drift.dart'; - -/// This table holds in-progress (draft) versions of documents that are not yet -/// been made public or submitted. -/// -/// [content] will be encrypted in future but we still need to be able to -/// search for drafts against fields like [title] for example. -/// -/// In future we may need to delete [title] or add more columns for search -/// purposes. If there will be too many requirements we may introduce -/// DraftsMetadata table, similar to [DocumentsMetadata]. -@TableIndex(name: 'idx_draft_type', columns: {#type}) -@DataClassName('DocumentDraftEntity') -class Drafts extends Table with IdHiLoTableMixin, VerHiLoTableMixin, DocumentTableMixin { - @override - Set>? get primaryKey => { - idHi, - idLo, - verHi, - verLo, - }; - - /// not encrypted title for search - TextColumn get title => text()(); -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/document_table_mixin.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/document_table_mixin.dart deleted file mode 100644 index ef56eb724d5c..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/document_table_mixin.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_repositories/src/database/table/converter/document_converters.dart'; -import 'package:drift/drift.dart'; - -mixin DocumentTableMixin on Table { - /// Encoded version of [DocumentData.content] - /// - /// Uses jsonb - BlobColumn get content => blob().map(DocumentConverters.content)(); - - /// Encoded version of [DocumentData.metadata] - /// - /// Uses jsonb - BlobColumn get metadata => blob().map(DocumentConverters.metadata)(); - - /// Refers to [DocumentType] uuid. - TextColumn get type => text().map(DocumentConverters.type)(); -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/id_table_mixin.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/id_table_mixin.dart deleted file mode 100644 index 48937e49339e..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/id_table_mixin.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:drift/drift.dart'; - -/// Commonly used pattern for representing uuid as id. -/// -/// See [UuidHiLo]. -mixin IdHiLoTableMixin on Table { - Int64Column get idHi => int64()(); - - Int64Column get idLo => int64()(); -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/ver_table_mixin.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/ver_table_mixin.dart deleted file mode 100644 index e0db6b3f6eac..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/ver_table_mixin.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:drift/drift.dart'; - -/// Commonly used pattern for representing uuid as ver. -/// -/// See [UuidHiLo]. -mixin VerHiLoTableMixin on Table { - Int64Column get verHi => int64()(); - - Int64Column get verLo => int64()(); -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/typedefs.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/typedefs.dart deleted file mode 100644 index ee69d75993dd..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/typedefs.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:catalyst_voices_repositories/src/database/table/documents.drift.dart'; -import 'package:catalyst_voices_repositories/src/database/table/documents_metadata.drift.dart'; - -/// Each document has list of metadata rows -typedef DocumentEntityWithMetadata = ({ - DocumentEntity document, - List metadata, -}); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart index 1f4e5bfaaf0d..339de477fb43 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart @@ -25,7 +25,6 @@ abstract interface class DocumentRepository { DraftDataSource drafts, SignedDocumentDataSource localDocuments, DocumentDataRemoteSource remoteDocuments, - DocumentFavoriteSource favoriteDocuments, ) = DocumentRepositoryImpl; /// Analyzes the database to gather statistics and potentially optimize it. @@ -113,10 +112,7 @@ abstract interface class DocumentRepository { required List refs, }); - /// Similar to [watchIsDocumentFavorite] but stops after first emit. - Future isDocumentFavorite({ - required DocumentRef ref, - }); + Future isFavorite(DocumentRef ref); /// Parses a document [data] previously encoded by [encodeDocumentForExport]. /// @@ -169,13 +165,6 @@ abstract interface class DocumentRepository { required CatalystId authorId, }); - /// Updates fav status matching [ref]. - Future updateDocumentFavorite({ - required DocumentRef ref, - required DocumentType type, - required bool isFavorite, - }); - /// In context of [document] selfRef: /// /// - [DraftRef] -> Creates/updates a local document draft. @@ -188,13 +177,6 @@ abstract interface class DocumentRepository { required DocumentData document, }); - /// Emits list of all favorite ids. - /// - /// All returned ids are loose and won't specify version. - Stream> watchAllDocumentsFavoriteIds({ - DocumentType? type, - }); - /// Emits number of matching documents Stream watchCount({ DocumentRef? refTo, @@ -225,11 +207,6 @@ abstract interface class DocumentRepository { DocumentRef? refTo, }); - /// Emits changes to fav status of [ref]. - Stream watchIsDocumentFavorite({ - required DocumentRef ref, - }); - /// Looking for document with matching refTo and type. /// It return documents data that have a reference that matches [refTo] /// @@ -249,7 +226,6 @@ final class DocumentRepositoryImpl implements DocumentRepository { final DraftDataSource _drafts; final SignedDocumentDataSource _localDocuments; final DocumentDataRemoteSource _remoteDocuments; - final DocumentFavoriteSource _favoriteDocuments; final _documentDataLock = Lock(); @@ -258,7 +234,6 @@ final class DocumentRepositoryImpl implements DocumentRepository { this._drafts, this._localDocuments, this._remoteDocuments, - this._favoriteDocuments, ); @override @@ -378,10 +353,9 @@ final class DocumentRepositoryImpl implements DocumentRepository { } @override - Future isDocumentFavorite({required DocumentRef ref}) { - assert(!ref.isExact, 'Favorite ref have to be loose!'); - - return _favoriteDocuments.watchIsDocumentFavorite(ref.id).first; + Future isFavorite(DocumentRef ref) { + // TODO: implement isFavorite + throw UnimplementedError(); } @override @@ -470,21 +444,6 @@ final class DocumentRepositoryImpl implements DocumentRepository { return newDocument.metadata.selfRef; } - @override - Future updateDocumentFavorite({ - required DocumentRef ref, - required DocumentType type, - required bool isFavorite, - }) { - assert(!ref.isExact, 'Favorite ref have to be loose!'); - - return _favoriteDocuments.updateDocumentFavorite( - ref.id, - type: type, - isFavorite: isFavorite, - ); - } - @override Future upsertDocument({ required DocumentData document, @@ -555,13 +514,6 @@ final class DocumentRepositoryImpl implements DocumentRepository { .distinct(listEquals); } - @override - Stream> watchAllDocumentsFavoriteIds({ - DocumentType? type, - }) { - return _favoriteDocuments.watchAllFavoriteIds(type: type); - } - @override Stream watchCount({ DocumentRef? refTo, @@ -647,13 +599,6 @@ final class DocumentRepositoryImpl implements DocumentRepository { }); } - @override - Stream watchIsDocumentFavorite({required DocumentRef ref}) { - assert(!ref.isExact, 'Favorite ref have to be loose!'); - - return _favoriteDocuments.watchIsDocumentFavorite(ref.id); - } - @override Stream watchRefToDocumentData({ required DocumentRef refTo, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_favorites_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_favorites_source.dart deleted file mode 100644 index bb8c8b3a8a64..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_favorites_source.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; - -final class DatabaseDocumentFavoriteSource implements DocumentFavoriteSource { - final CatalystDatabase _database; - - DatabaseDocumentFavoriteSource( - this._database, - ); - - @override - Future deleteAll() => _database.favoritesDao.deleteAll(); - - @override - Future updateDocumentFavorite( - String id, { - required DocumentType type, - required bool isFavorite, - }) { - if (!isFavorite) { - return _database.favoritesDao.deleteWhere(id: id); - } - - final idHiLo = UuidHiLo.from(id); - final entity = DocumentFavoriteEntity( - idHi: idHiLo.high, - idLo: idHiLo.low, - isFavorite: isFavorite, - type: type, - ); - - return _database.favoritesDao.save(entity); - } - - @override - Stream> watchAllFavoriteIds({DocumentType? type}) { - return _database.favoritesDao.watchAll(type: type); - } - - @override - Stream watchIsDocumentFavorite(String id) { - return _database.favoritesDao.watch(id: id); - } -} - -abstract interface class DocumentFavoriteSource { - Future deleteAll(); - - Future updateDocumentFavorite( - String id, { - required DocumentType type, - required bool isFavorite, - }); - - Stream> watchAllFavoriteIds({ - DocumentType? type, - }); - - Stream watchIsDocumentFavorite(String id); -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/catalyst_database_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/catalyst_database_test.dart index a825a76f3365..b1d9f4670a16 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/catalyst_database_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/catalyst_database_test.dart @@ -1,7 +1,7 @@ -import 'package:catalyst_voices_dev/catalyst_voices_dev.dart'; import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../utils/document_with_authors_factory.dart'; import 'connection/test_connection.dart'; import 'drift_test_platforms.dart'; @@ -22,29 +22,20 @@ void main() { 'clear removes all documents and drafts', () async { // Given - final drafts = List.generate(5, (index) => DraftFactory.build()); - final documents = List.generate( - 5, - (index) => DocumentWithMetadataFactory.build(), - ); + final documents = List.generate(5, (index) => DocumentWithAuthorsFactory.create()); // When - await database.documentsDao.saveAll(documents); - await database.draftsDao.saveAll(drafts); + await database.documentsV2Dao.saveAll(documents); // Then - final draftsCountBefore = await database.draftsDao.count(); - final documentsCountBefore = await database.documentsDao.count(); + final documentsCountBefore = await database.documentsV2Dao.count(); - expect(draftsCountBefore, drafts.length); expect(documentsCountBefore, documents.length); await database.clear(); - final draftsCountAfter = await database.draftsDao.count(); - final documentsCountAfter = await database.documentsDao.count(); + final documentsCountAfter = await database.documentsV2Dao.count(); - expect(draftsCountAfter, isZero); expect(documentsCountAfter, isZero); }, onPlatform: driftOnPlatforms, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_dao_test.dart deleted file mode 100644 index 772785ecae2c..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_dao_test.dart +++ /dev/null @@ -1,1249 +0,0 @@ -import 'package:catalyst_voices_dev/catalyst_voices_dev.dart'; -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; -import 'package:catalyst_voices_repositories/src/database/dao/documents_dao.dart'; -import 'package:catalyst_voices_repositories/src/database/database.dart'; -import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:uuid_plus/uuid_plus.dart'; - -import '../connection/test_connection.dart'; -import '../drift_test_platforms.dart'; - -void main() { - late DriftCatalystDatabase database; - - // ignore: unnecessary_lambdas - setUpAll(() { - DummyCatalystIdFactory.registerDummyKeyFactory(); - }); - - setUp(() async { - final connection = await buildTestConnection(); - database = DriftCatalystDatabase(connection); - }); - - tearDown(() async { - await database.close(); - }); - - group(DocumentsDao, () { - group('save all', () { - test( - 'documents can be queried back correctly', - () async { - // Given - final documentsWithMetadata = _generateDocumentEntitiesWithMetadata(10); - final expectedDocuments = documentsWithMetadata.map((e) => e.document); - - // When - await database.documentsDao.saveAll(documentsWithMetadata); - - // Then - final documents = await database.documentsDao.queryAll(); - - expect(documents, expectedDocuments); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'conflicting documents are ignored', - () async { - // Given - final documentsWithMetadata = _generateDocumentEntitiesWithMetadata(20); - final expectedDocuments = documentsWithMetadata.map((e) => e.document); - - // When - final firstBatch = documentsWithMetadata.sublist(0, 10); - final secondBatch = documentsWithMetadata.sublist(5); - - await database.documentsDao.saveAll(firstBatch); - await database.documentsDao.saveAll(secondBatch); - - // Then - final documents = await database.documentsDao.queryAll(); - - expect(documents, expectedDocuments); - }, - onPlatform: driftOnPlatforms, - ); - }); - - group('query', () { - test( - 'stream emits data when new entities are saved', - () async { - // Given - final documentsWithMetadata = _generateDocumentEntitiesWithMetadata(1); - final expectedDocuments = documentsWithMetadata.map((e) => e.document).toList(); - - // When - final documentsStream = database.documentsDao.watchAll(); - - await database.documentsDao.saveAll(documentsWithMetadata); - - // Then - expect( - documentsStream, - emitsInOrder([ - // later we inserting documents - equals(expectedDocuments), - ]), - ); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns specific version matching exact ref', - () async { - // Given - final documentsWithMetadata = _generateDocumentEntitiesWithMetadata(2); - final document = documentsWithMetadata.first.document; - final ref = SignedDocumentRef( - id: document.metadata.id, - version: document.metadata.version, - ); - - // When - await database.documentsDao.saveAll(documentsWithMetadata); - - // Then - final entity = await database.documentsDao.query(ref: ref); - - expect(entity, isNotNull); - - final id = UuidHiLo(high: entity!.idHi, low: entity.idLo); - final ver = UuidHiLo(high: entity.verHi, low: entity.verLo); - - expect(id.uuid, ref.id); - expect(ver.uuid, ref.version); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns newest version when ver is not specified', - () async { - // Given - final id = DocumentRefFactory.randomUuidV7(); - final firstVersionId = const Uuid().v7( - config: V7Options( - DateTime(2025, 2, 10).millisecondsSinceEpoch, - null, - ), - ); - final secondVersionId = const Uuid().v7( - config: V7Options( - DateTime(2025, 2, 11).millisecondsSinceEpoch, - null, - ), - ); - - const secondContent = DocumentDataContent({'title': 'Dev'}); - final documentsWithMetadata = [ - DocumentWithMetadataFactory.build( - content: const DocumentDataContent({'title': 'D'}), - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: SignedDocumentRef( - id: id, - version: firstVersionId, - ), - ), - ), - DocumentWithMetadataFactory.build( - content: secondContent, - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: SignedDocumentRef( - id: id, - version: secondVersionId, - ), - ), - ), - ]; - final document = documentsWithMetadata.first.document; - final ref = SignedDocumentRef(id: document.metadata.id); - - // When - await database.documentsDao.saveAll(documentsWithMetadata); - - // Then - final entity = await database.documentsDao.query(ref: ref); - - expect(entity, isNotNull); - - expect(entity!.metadata.id, id); - expect(entity.metadata.version, secondVersionId); - expect(entity.content, secondContent); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns null when id does not match any id', - () async { - // Given - final documentsWithMetadata = _generateDocumentEntitiesWithMetadata(2); - final ref = SignedDocumentRef(id: DocumentRefFactory.randomUuidV7()); - - // When - await database.documentsDao.saveAll(documentsWithMetadata); - - // Then - final entity = await database.documentsDao.query(ref: ref); - - expect(entity, isNull); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'Return latest unique documents', - () async { - final id = DocumentRefFactory.randomUuidV7(); - final version = DocumentRefFactory.randomUuidV7(); - final version2 = DocumentRefFactory.randomUuidV7(); - - final document = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: SignedDocumentRef(id: id, version: version), - ), - ); - final document2 = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: SignedDocumentRef(id: id, version: version2), - ), - ); - final documentsStream = database.documentsDao.watchAll(unique: true).asBroadcastStream(); - - await database.documentsDao.saveAll([document]); - final firstEmission = await documentsStream.first; - await database.documentsDao.saveAll([document2]); - final secondEmission = await documentsStream.first; - - expect(firstEmission, equals([document.document])); - expect(secondEmission, equals([document2.document])); - expect(secondEmission.length, equals(1)); - expect( - secondEmission.first.metadata.selfRef.version, - equals(version2), - ); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'Returns latest document limited by quantity if provided', - () async { - // Given - final documentsWithMetadata = _generateDocumentEntitiesWithMetadata(20); - - final expectedDocuments = - documentsWithMetadata - .map((e) => e.document) - .groupListsBy((doc) => '${doc.idHi}-${doc.idLo}') - .values - .map( - (versions) => versions.reduce((a, b) { - // Compare versions (higher version wins) - final compareHi = b.verHi.compareTo(a.verHi); - if (compareHi != 0) return compareHi > 0 ? b : a; - return b.verLo.compareTo(a.verLo) > 0 ? b : a; - }), - ) - .toList() - ..sort((a, b) { - // Sort by version descending - final compareHi = b.verHi.compareTo(a.verHi); - if (compareHi != 0) return compareHi; - return b.verLo.compareTo(a.verLo); - }); - - final limitedExpectedDocuments = expectedDocuments.take(7).toList(); - - // When - final documentsStream = database.documentsDao.watchAll(limit: 7, unique: true); - - await database.documentsDao.saveAll(documentsWithMetadata.reversed); - - // Then - expect( - documentsStream, - emitsInOrder([ - equals(limitedExpectedDocuments), - ]), - ); - - expect( - limitedExpectedDocuments.length, - equals(7), - reason: 'should have 7 documents', - ); - - final uniqueIds = limitedExpectedDocuments.map((d) => '${d.idHi}-${d.idLo}').toSet(); - expect( - uniqueIds.length, - equals(limitedExpectedDocuments.length), - reason: 'should have unique document IDs', - ); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns latest version when document has more than 1 version', - () async { - final id = DocumentRefFactory.randomUuidV7(); - final v1 = DocumentRefFactory.randomUuidV7(); - final v2 = DocumentRefFactory.randomUuidV7(); - - final documentsWithMetadata = [v1, v2].map((version) { - final metadata = DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: SignedDocumentRef( - id: id, - version: version, - ), - ); - return DocumentWithMetadataFactory.build(metadata: metadata); - }).toList(); - - // When - final documentsStream = database.documentsDao.watchAll(limit: 7, unique: true); - - await database.documentsDao.saveAll(documentsWithMetadata); - // Then - expect( - documentsStream, - emitsInOrder([ - predicate>( - (documents) { - if (documents.length != 1) return false; - final doc = documents.first; - return doc.metadata.version == v2; - }, - 'should return document with version $v2', - ), - ]), - ); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'emits new version of recent document', - () async { - // Generate base ID - final id = DocumentRefFactory.randomUuidV7(); - - // Create versions with enforced order (v2 is newer than v1) - final v1 = DocumentRefFactory.randomUuidV7(); - final v2 = DocumentRefFactory.randomUuidV7(); - - final documentsWithMetadata = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: SignedDocumentRef( - id: id, - version: v1, - ), - ), - ); - - final newVersion = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: SignedDocumentRef( - id: id, - version: v2, - ), - ), - ); - - // When - final documentsStream = database.documentsDao - .watchAll(limit: 7, unique: true) - .asBroadcastStream(); - - // Save first version and wait for emission - await database.documentsDao.saveAll([documentsWithMetadata]); - final firstEmission = await documentsStream.first; - - // Save second version and wait for emission - await database.documentsDao.saveAll([newVersion]); - final secondEmission = await documentsStream.first; - - // Then verify both emissions - expect(firstEmission, equals([documentsWithMetadata.document])); - expect(secondEmission, equals([newVersion.document])); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'emits new document when is inserted', - () async { - // Generate base ID - final id1 = DocumentRefFactory.randomUuidV7(); - - // Create versions with enforced order (v2 is newer than v1) - final v1 = DocumentRefFactory.randomUuidV7(); - - final id2 = DocumentRefFactory.randomUuidV7(); - final v2 = DocumentRefFactory.randomUuidV7(); - - final document1 = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: SignedDocumentRef( - id: id1, - version: v1, - ), - ), - ); - - final document2 = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: SignedDocumentRef( - id: id2, - version: v2, - ), - ), - ); - - // When - final documentsStream = database.documentsDao - .watchAll(limit: 1, unique: true) - .asBroadcastStream(); - - await database.documentsDao.saveAll([document1]); - final firstEmission = await documentsStream.first; - - await database.documentsDao.saveAll([document2]); - final secondEmission = await documentsStream.first; - - // Then verify both emissions - expect(firstEmission, equals([document1.document])); - expect( - secondEmission, - equals([document2.document]), - ); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'all documents with from same account are returned ' - 'even when username changes', - () async { - // Given - final originalId = DummyCatalystIdFactory.create(username: 'damian'); - final updatedId = originalId.copyWith(username: const Optional('dev')); - - final document1 = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DocumentRefFactory.signedDocumentRef(), - authors: [originalId], - ), - ); - - final document2 = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DocumentRefFactory.signedDocumentRef(), - authors: [updatedId], - ), - ); - - final docs = [document1, document2]; - final refs = docs.map((e) => e.document.metadata.selfRef).toList(); - - // When - await database.documentsDao.saveAll(docs); - - // Then - final stream = database.documentsDao.watchAll(authorId: updatedId); - - expect( - stream, - emitsInOrder([ - allOf( - hasLength(docs.length), - everyElement( - predicate((document) { - return refs.contains(document.metadata.selfRef); - }), - ), - ), - ]), - ); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'queryRefToDocumentData returns correct document', - () async { - final document1 = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DocumentRefFactory.signedDocumentRef(), - ), - ); - final document2 = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DocumentRefFactory.signedDocumentRef(), - ref: document1.document.metadata.selfRef, - ), - ); - - await database.documentsDao.saveAll([document1, document2]); - - final document = await database.documentsDao.queryRefToDocumentData( - refTo: document1.document.metadata.selfRef, - type: DocumentType.proposalDocument, - ); - - expect( - document?.metadata.selfRef, - document2.document.metadata.selfRef, - ); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'watchRefToDocumentData emits correct document and updates', - () async { - // Given - final baseDocument = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DocumentRefFactory.signedDocumentRef(), - ), - ); - - final referencingDocument = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.commentTemplate, - selfRef: DocumentRefFactory.signedDocumentRef(), - ref: baseDocument.document.metadata.selfRef, - ), - ); - - final newerVersion = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.commentTemplate, - selfRef: SignedDocumentRef( - id: referencingDocument.document.metadata.id, - version: DocumentRefFactory.randomUuidV7(), - ), - ref: baseDocument.document.metadata.selfRef, - ), - ); - - // When - final documentsStream = database.documentsDao - .watchRefToDocumentData( - refTo: baseDocument.document.metadata.selfRef, - type: DocumentType.commentTemplate, - ) - .asBroadcastStream(); - - await database.documentsDao.saveAll([baseDocument, referencingDocument]); - final firstEmission = await documentsStream.first; - - await database.documentsDao.saveAll([newerVersion]); - final secondEmission = await documentsStream.first; - - // Then - expect( - firstEmission?.metadata.selfRef, - referencingDocument.document.metadata.selfRef, - ); - expect( - secondEmission?.metadata.selfRef, - newerVersion.document.metadata.selfRef, - ); - expect( - secondEmission?.metadata.id, - referencingDocument.document.metadata.id, - ); - }, - onPlatform: driftOnPlatforms, - ); - group('wildcard support', () { - test( - 'can query documents by matched DocumentNodeId value with wildcard', - () async { - final templateRef = DocumentRefFactory.randomUuidV7(); - - final proposalRef1 = DocumentRefFactory.randomUuidV7(); - final proposalRef2 = DocumentRefFactory.randomUuidV7(); - - const content1 = DocumentDataContent({ - 'setup': { - 'title': { - 'title': 'Milestone 2', - }, - }, - 'milestones': { - 'milestones': { - 'milestone_list': [ - { - 'title': 'Milestone 1', - 'cost': 100, - }, - { - 'title': 'Milestone 2', - 'cost': 200, - }, - ], - }, - }, - }); - - const content2 = DocumentDataContent({ - 'setup': { - 'title': { - 'title': 'Milestone 2', - }, - }, - 'milestones': { - 'milestones': { - 'milestone_list': [ - { - 'title': 'Milestone 1', - 'cost': 100, - }, - { - 'title': 'Milestone 1', - 'outputs': 'Milestone 2', - 'cost': 200, - }, - ], - }, - }, - }); - - final ref1 = SignedDocumentRef(id: proposalRef1, version: proposalRef1); - final ref2 = SignedDocumentRef(id: proposalRef2, version: proposalRef2); - - final doc1 = DocumentWithMetadataFactory.build( - content: content1, - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: ref1, - template: SignedDocumentRef(id: templateRef, version: templateRef), - ), - ); - - final doc2 = DocumentWithMetadataFactory.build( - content: content2, - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: ref2, - template: SignedDocumentRef(id: templateRef, version: templateRef), - ), - ); - - await database.documentsDao.saveAll([doc1, doc2]); - - // When: query for documents with milestone_list.*.title == 'Milestone 2' - final results = await (database.documentsDao as DriftDocumentsDao) - .queryDocumentsByMatchedDocumentNodeIdValue( - nodeId: DocumentNodeId.fromString('milestones.milestones.milestone_list.*.title'), - value: 'Milestone 2', - type: DocumentType.proposalDocument, - content: 'content', - ); - - // Then: only doc1 should be returned - final refs = results.map((e) => e.metadata.selfRef).toList(); - expect(refs, contains(ref1)); - expect(refs, isNot(contains(ref2))); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'can query documents by matched DocumentNodeId value without wildcard', - () async { - final templateRef = DocumentRefFactory.randomUuidV7(); - - final proposalRef1 = DocumentRefFactory.randomUuidV7(); - - const content1 = DocumentDataContent({ - 'setup': { - 'proposer': { - 'applicant': 'John Doe', - }, - 'title': { - 'title': 'Milestone 2', - }, - }, - 'milestones': { - 'milestones': { - 'milestone_list': [ - { - 'title': 'Milestone 1', - 'cost': 100, - }, - { - 'title': 'Milestone 2', - 'cost': 200, - }, - ], - }, - }, - }); - - final ref1 = SignedDocumentRef(id: proposalRef1, version: proposalRef1); - - final doc1 = DocumentWithMetadataFactory.build( - content: content1, - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: ref1, - template: SignedDocumentRef(id: templateRef, version: templateRef), - ), - ); - - await database.documentsDao.saveAll([doc1]); - - final results = await (database.documentsDao as DriftDocumentsDao) - .queryDocumentsByMatchedDocumentNodeIdValue( - nodeId: ProposalDocument.authorNameNodeId, - value: 'John Doe', - type: DocumentType.proposalDocument, - content: 'content', - ); - - final refs = results.map((e) => e.metadata.selfRef).toList(); - expect(refs, contains(ref1)); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'can query documents by matched DocumentNodeId value with wildcard at the beginning', - () async { - final templateRef = DocumentRefFactory.randomUuidV7(); - - final proposalRef1 = DocumentRefFactory.randomUuidV7(); - final proposalRef2 = DocumentRefFactory.randomUuidV7(); - - const content1 = DocumentDataContent({ - 'setup': { - 'title': { - 'title': 'Milestone 2', - 'subtitle': 'Subtitle', - }, - }, - 'milestones': { - 'milestones': { - 'milestone_list': [ - { - 'title': 'Milestone 1', - 'cost': 100, - }, - { - 'title': 'Milestone 2', - 'cost': 200, - }, - ], - }, - }, - }); - - const content2 = DocumentDataContent({ - 'setup': { - 'title': { - 'title': 'Milestone 2', - }, - }, - 'milestones': { - 'milestones': { - 'milestone_list': [ - { - 'title': 'Milestone 1', - 'cost': 100, - }, - { - 'title': 'Milestone 1', - 'outputs': 'Milestone 2', - 'cost': 200, - }, - ], - }, - }, - }); - - final ref1 = SignedDocumentRef(id: proposalRef1, version: proposalRef1); - final ref2 = SignedDocumentRef(id: proposalRef2, version: proposalRef2); - - final doc1 = DocumentWithMetadataFactory.build( - content: content1, - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: ref1, - template: SignedDocumentRef(id: templateRef, version: templateRef), - ), - ); - - final doc2 = DocumentWithMetadataFactory.build( - content: content2, - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: ref2, - template: SignedDocumentRef(id: templateRef, version: templateRef), - ), - ); - - await database.documentsDao.saveAll([doc1, doc2]); - - // When: query for documents with milestone_list.*.title == 'Milestone 2' - final results = await (database.documentsDao as DriftDocumentsDao) - .queryDocumentsByMatchedDocumentNodeIdValue( - nodeId: DocumentNodeId.fromString('*.subtitle'), - value: 'Subtitle', - type: DocumentType.proposalDocument, - content: 'content', - ); - - // Then: only doc1 should be returned - final refs = results.map((e) => e.metadata.selfRef).toList(); - expect(refs, contains(ref1)); - expect(refs, isNot(contains(ref2))); - }, - onPlatform: driftOnPlatforms, - ); - }); - }); - - group('count', () { - test( - 'document returns expected number', - () async { - // Given - final dateTime = DateTimeExt.now(); - - final documentsWithMetadata = List.generate( - 20, - (index) => DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: _buildRefAt(dateTime.add(Duration(seconds: index))), - ), - ), - ); - - // When - await database.documentsDao.saveAll(documentsWithMetadata); - - // Then - final count = await database.documentsDao.countDocuments(); - - expect(count, documentsWithMetadata.length); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'two versions of same document will be counted as one', - () async { - // Given - final id = DocumentRefFactory.randomUuidV7(); - final documentsWithMetadata = List.generate( - 2, - (index) { - final metadata = DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: SignedDocumentRef(id: id, version: DocumentRefFactory.randomUuidV7()), - ); - return DocumentWithMetadataFactory.build(metadata: metadata); - }, - ); - - // When - await database.documentsDao.saveAll(documentsWithMetadata); - - // Then - final count = await database.documentsDao.countDocuments(); - - expect(count, 1); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'where without ver counts all versions', - () async { - // Given - final id = DocumentRefFactory.randomUuidV7(); - final documentsWithMetadata = List.generate( - 2, - (index) { - final metadata = DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: SignedDocumentRef(id: id, version: DocumentRefFactory.randomUuidV7()), - ); - return DocumentWithMetadataFactory.build(metadata: metadata); - }, - ); - - final expectedCount = documentsWithMetadata.length; - final ref = SignedDocumentRef(id: id); - - // When - await database.documentsDao.saveAll(documentsWithMetadata); - - // Then - final count = await database.documentsDao.count(ref: ref); - - expect(count, expectedCount); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'where with ver counts only matching results', - () async { - // Given - final id = DocumentRefFactory.randomUuidV7(); - final documentsWithMetadata = List.generate( - 2, - (index) { - final metadata = DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: SignedDocumentRef(id: id, version: DocumentRefFactory.randomUuidV7()), - ); - return DocumentWithMetadataFactory.build(metadata: metadata); - }, - ); - final version = documentsWithMetadata.first.document.metadata.version; - final ref = SignedDocumentRef(id: id, version: version); - - // When - await database.documentsDao.saveAll(documentsWithMetadata); - - // Then - final count = await database.documentsDao.count(ref: ref); - - expect(count, 1); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'where returns correct value when ' - 'many different documents are found', - () async { - // Given - final documentsWithMetadata = List.generate( - 10, - (index) { - final metadata = DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DocumentRefFactory.signedDocumentRef(), - ); - return DocumentWithMetadataFactory.build(metadata: metadata); - }, - ); - final document = documentsWithMetadata.last.document; - final id = document.metadata.id; - final version = document.metadata.version; - final ref = SignedDocumentRef(id: id, version: version); - - // When - await database.documentsDao.saveAll(documentsWithMetadata); - - // Then - final count = await database.documentsDao.count(ref: ref); - - expect(count, 1); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'Counts comments for specific proposal document version', - () async { - final proposalId = DocumentRefFactory.randomUuidV7(); - final versionId = DocumentRefFactory.randomUuidV7(); - final proposalRef = SignedDocumentRef( - id: proposalId, - version: versionId, - ); - final proposal = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: proposalRef, - ), - ); - - await database.documentsDao.saveAll([proposal]); - - final comments = List.generate( - 10, - (index) => DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.commentTemplate, - selfRef: DocumentRefFactory.signedDocumentRef(), - ref: proposalRef, - ), - ), - ); - final otherComments = List.generate( - 5, - (index) => DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.commentTemplate, - selfRef: DocumentRefFactory.signedDocumentRef(), - ref: DocumentRefFactory.signedDocumentRef(), - ), - ), - ); - await database.documentsDao.saveAll([...comments, ...otherComments]); - - final count = await database.documentsDao.countRefDocumentByType( - ref: proposalRef, - type: DocumentType.commentTemplate, - ); - - expect(count, equals(10)); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'Count versions of specific document', - () async { - final proposalId = DocumentRefFactory.randomUuidV7(); - final versionId = DocumentRefFactory.randomUuidV7(); - final proposalRef = SignedDocumentRef( - id: proposalId, - version: versionId, - ); - final proposal = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: proposalRef, - ), - ); - - await database.documentsDao.saveAll([proposal]); - - final versions = List.generate( - 10, - (index) { - return DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: SignedDocumentRef( - id: proposalId, - version: DocumentRefFactory.randomUuidV7(), - ), - ref: proposalRef, - ), - ); - }, - ); - - await database.documentsDao.saveAll(versions); - - final ids = await database.documentsDao.queryVersionsOfId( - id: proposalId, - ); - - expect(ids.length, equals(11)); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'Watches comments count', - () async { - final proposalId = DocumentRefFactory.randomUuidV7(); - final versionId = DocumentRefFactory.randomUuidV7(); - final proposalId2 = DocumentRefFactory.randomUuidV7(); - - final proposalRef = SignedDocumentRef( - id: proposalId, - version: versionId, - ); - final proposal = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: proposalRef, - ), - ); - - final proposalRef2 = SignedDocumentRef( - id: proposalId2, - version: versionId, - ); - - await database.documentsDao.saveAll([proposal]); - - final comments = List.generate(2, (index) { - return DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.commentTemplate, - selfRef: DocumentRefFactory.signedDocumentRef(), - ref: proposalRef, - ), - ); - }); - - final otherComment = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.commentTemplate, - selfRef: DocumentRefFactory.signedDocumentRef(), - ref: proposalRef2, - ), - ); - - await database.documentsDao.saveAll([comments.first, otherComment]); - - final documentCount = database.documentsDao - .watchCount( - refTo: proposalRef, - type: DocumentType.commentTemplate, - ) - .asBroadcastStream(); - - final firstEmission = await documentCount.first; - - expect(firstEmission, equals(1)); - - await database.documentsDao.saveAll([comments.last]); - final secondEmission = await documentCount.first; - expect(secondEmission, equals(2)); - }, - onPlatform: driftOnPlatforms, - ); - }); - - group('delete all', () { - test( - 'removes all documents', - () async { - // Given - final documentsWithMetadata = List.generate( - 5, - (index) => DocumentWithMetadataFactory.build(), - ); - - // When - await database.documentsDao.saveAll(documentsWithMetadata); - - // Then - final countBefore = await database.documentsDao.countDocuments(); - expect(countBefore, isNonZero); - - await database.documentsDao.deleteAll(); - - final countAfter = await database.documentsDao.countDocuments(); - expect(countAfter, isZero); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'cascades metadata', - () async { - // Given - final documentsWithMetadata = List.generate( - 5, - (index) => DocumentWithMetadataFactory.build(), - ); - - // When - await database.documentsDao.saveAll(documentsWithMetadata); - - // Then - final before = await database.documentsDao.countDocumentsMetadata(); - expect(before, isNonZero); - - await database.documentsDao.deleteAll(); - - final after = await database.documentsDao.countDocumentsMetadata(); - expect(after, isZero); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'templates used in local drafts are kept when flat is true', - () async { - // Given - final template = DocumentWithMetadataFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalTemplate, - selfRef: DocumentRefFactory.signedDocumentRef(), - ), - ); - - final localDraft = DraftFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DocumentRefFactory.draftRef(), - template: template.document.metadata.selfRef as SignedDocumentRef, - ), - ); - - final randomDocuments = List.generate( - 10, - (index) => DocumentWithMetadataFactory.build(), - ); - - final allDocuments = [ - template, - ...randomDocuments, - ]; - - final allDrafts = [ - localDraft, - ]; - - // When - await database.documentsDao.saveAll(allDocuments); - await database.draftsDao.saveAll(allDrafts); - - // Then - await database.documentsDao.deleteAll(keepTemplatesForLocalDrafts: true); - - final documentsCount = await database.documentsDao.count(); - final draftsCount = await database.draftsDao.count(); - - expect(documentsCount, 1); - expect(draftsCount, 1); - }, - onPlatform: driftOnPlatforms, - ); - }); - }); -} - -SignedDocumentRef _buildRefAt(DateTime dateTime) { - final config = V7Options(dateTime.millisecondsSinceEpoch, null); - final val = const Uuid().v7(config: config); - return SignedDocumentRef.first(val); -} - -List _generateDocumentEntitiesWithMetadata(int count) { - return List.generate( - count, - (index) => DocumentWithMetadataFactory.build(), - ); -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart index 6fcf37bfc12e..4359951c06a6 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart @@ -5,13 +5,10 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; import 'package:catalyst_voices_repositories/src/database/dao/documents_v2_dao.dart'; import 'package:catalyst_voices_repositories/src/database/model/document_with_authors_entity.dart'; -import 'package:catalyst_voices_repositories/src/database/table/document_authors.drift.dart'; import 'package:catalyst_voices_repositories/src/database/table/documents_v2.drift.dart'; -import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:uuid_plus/uuid_plus.dart'; +import '../../utils/document_with_authors_factory.dart'; import '../connection/test_connection.dart'; void main() { @@ -1013,11 +1010,7 @@ void main() { }); } -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)); -} +String _buildUuidV7At(DateTime dateTime) => DocumentRefFactory.uuidV7At(dateTime); DocumentWithAuthorsEntity _createTestDocumentEntity({ String? id, @@ -1035,15 +1028,10 @@ DocumentWithAuthorsEntity _createTestDocumentEntity({ String? templateId, String? templateVer, }) { - id ??= DocumentRefFactory.randomUuidV7(); - ver ??= id; - authors ??= ''; - - final docEntity = DocumentEntityV2( + return DocumentWithAuthorsFactory.create( id: id, ver: ver, - content: DocumentDataContent(contentData), - createdAt: ver.tryDateTime ?? DateTime.timestamp(), + contentData: contentData, type: type, authors: authors, categoryId: categoryId, @@ -1056,22 +1044,4 @@ DocumentWithAuthorsEntity _createTestDocumentEntity({ templateId: templateId, templateVer: templateVer, ); - - final authorsEntities = authors - .split(',') - .where((element) => element.trim().isNotEmpty) - .map(CatalystId.tryParse) - .nonNulls - .map( - (e) => DocumentAuthorEntity( - documentId: docEntity.id, - documentVer: docEntity.ver, - authorId: e.toUri().toString(), - authorIdSignificant: e.toSignificant().toUri().toString(), - authorUsername: e.username, - ), - ) - .toList(); - - return DocumentWithAuthorsEntity(docEntity, authorsEntities); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/drafts_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/drafts_dao_test.dart deleted file mode 100644 index d83cadc749df..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/drafts_dao_test.dart +++ /dev/null @@ -1,439 +0,0 @@ -import 'package:catalyst_voices_dev/catalyst_voices_dev.dart'; -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; -import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; -import 'package:catalyst_voices_repositories/src/database/dao/drafts_dao.dart'; -import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:drift/drift.dart' show Uint8List; -import 'package:flutter_test/flutter_test.dart'; -import 'package:uuid_plus/uuid_plus.dart'; - -import '../connection/test_connection.dart'; -import '../drift_test_platforms.dart'; - -void main() { - late DriftCatalystDatabase database; - - setUp(() async { - final connection = await buildTestConnection(); - database = DriftCatalystDatabase(connection); - }); - - tearDown(() async { - await database.close(); - }); - - group(DriftDraftsDao, () { - group('query', () { - test( - 'returns specific version matching exact ref', - () async { - // Given - final drafts = List.generate( - 2, - (index) => DraftFactory.build(), - ); - final ref = DraftRef( - id: drafts.first.metadata.id, - version: drafts.first.metadata.version, - ); - - // When - await database.draftsDao.saveAll(drafts); - - // Then - final entity = await database.draftsDao.query(ref: ref); - - expect(entity, isNotNull); - - final id = UuidHiLo(high: entity!.idHi, low: entity.idLo); - final ver = UuidHiLo(high: entity.verHi, low: entity.verLo); - - expect(id.uuid, ref.id); - expect(ver.uuid, ref.version); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns newest version when ver is not specified', - () async { - // Given - final id = DocumentRefFactory.randomUuidV7(); - final firstVersionId = const Uuid().v7( - config: V7Options( - DateTime(2025, 2, 10).millisecondsSinceEpoch, - null, - ), - ); - final secondVersionId = const Uuid().v7( - config: V7Options( - DateTime(2025, 2, 11).millisecondsSinceEpoch, - null, - ), - ); - - final drafts = [ - DraftFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DraftRef(id: id, version: firstVersionId), - ), - ), - DraftFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DraftRef(id: id, version: secondVersionId), - ), - ), - ]; - final ref = DraftRef(id: drafts.first.metadata.id); - - // When - await database.draftsDao.saveAll(drafts); - - // Then - final entity = await database.draftsDao.query(ref: ref); - - expect(entity, isNotNull); - - expect(entity!.metadata.id, id); - expect(entity.metadata.version, secondVersionId); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns null when id does not match any id', - () async { - // Given - final drafts = List.generate( - 2, - (index) => DraftFactory.build(), - ); - final ref = DraftRef(id: DocumentRefFactory.randomUuidV7()); - - // When - await database.draftsDao.saveAll(drafts); - - // Then - final entity = await database.draftsDao.query(ref: ref); - - expect(entity, isNull); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'authors are correctly extracted', - () async { - final authorId1 = CatalystId(host: 'test', role0Key: Uint8List(32)); - final authorId2 = CatalystId(host: 'test1', role0Key: Uint8List(32)); - - final ref = DocumentRefFactory.draftRef(); - // Given - final draft = DraftFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: ref, - authors: [ - authorId1, - authorId2, - ], - ), - ); - - await database.draftsDao.save(draft); - final doc = await database.draftsDao.query(ref: ref); - expect( - doc?.metadata.authors, - [ - authorId1, - authorId2, - ], - ); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'when updating proposal author list is not deleted', - () async { - final authorId1 = CatalystId(host: 'test', role0Key: Uint8List(32)); - final authorId2 = CatalystId(host: 'test1', role0Key: Uint8List(32)); - - final ref = DocumentRefFactory.draftRef(); - // Given - final draft = DraftFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: ref, - authors: [ - authorId1, - authorId2, - ], - ), - content: const DocumentDataContent({ - 'title': 'Dev', - }), - ); - - final updateDraft = draft.copyWith( - metadata: draft.metadata.copyWith(), - content: const DocumentDataContent({ - 'title': 'Update', - }), - ); - - await database.draftsDao.save(draft); - await database.draftsDao.save(updateDraft); - - final updated = await database.draftsDao.query(ref: ref); - - expect( - updated?.metadata.authors?.length, - equals(2), - ); - expect( - updated?.metadata.authors, - equals([ - authorId1, - authorId2, - ]), - ); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'all drafts with from same account are returned ' - 'even when username changes', - () async { - // Given - final originalId = DummyCatalystIdFactory.create(username: 'damian'); - final updatedId = originalId.copyWith(username: const Optional('dev')); - - final draft1 = DraftFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DocumentRefFactory.signedDocumentRef(), - authors: [originalId], - ), - ); - final draft2 = DraftFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DocumentRefFactory.signedDocumentRef(), - authors: [updatedId], - ), - ); - - final drafts = [draft1, draft2]; - final refs = drafts.map((e) => e.metadata.selfRef).toList(); - - // When - await database.draftsDao.saveAll(drafts); - - // Then - final stream = database.draftsDao.watchAll(authorId: updatedId); - - expect( - stream, - emitsInOrder([ - allOf( - hasLength(drafts.length), - everyElement( - predicate((document) { - return refs.contains(document.metadata.selfRef); - }), - ), - ), - ]), - ); - }, - onPlatform: driftOnPlatforms, - ); - }); - - group('count', () { - test( - 'ref without ver includes all versions', - () async { - // Given - final id = DocumentRefFactory.randomUuidV7(); - final drafts = List.generate( - 2, - (index) { - return DraftFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DraftRef( - id: id, - version: DocumentRefFactory.randomUuidV7(), - ), - ), - ); - }, - ); - final ref = DraftRef(id: id); - - // When - await database.draftsDao.saveAll(drafts); - - // Then - final count = await database.draftsDao.count(ref: ref); - - expect(count, drafts.length); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'ref with ver includes only that version', - () async { - // Given - final id = DocumentRefFactory.randomUuidV7(); - final version = DocumentRefFactory.randomUuidV7(); - final drafts = [ - DraftFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DraftRef(id: id, version: version), - ), - ), - DraftFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DraftRef.first(id), - ), - ), - ]; - final ref = DraftRef(id: id, version: version); - - // When - await database.draftsDao.saveAll(drafts); - - // Then - final count = await database.draftsDao.count(ref: ref); - - expect(count, 1); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns 0 whe no matching drafts are found', - () async { - // Given - final drafts = [ - DraftFactory.build(), - DraftFactory.build(), - ]; - - final ref = DraftRef(id: DocumentRefFactory.randomUuidV7()); - - // When - await database.draftsDao.saveAll(drafts); - - // Then - final count = await database.draftsDao.count(ref: ref); - - expect(count, 0); - }, - onPlatform: driftOnPlatforms, - ); - }); - - group('update', () { - test( - 'replaces content correctly for exact ref', - () async { - // Given - final draft = DraftFactory.build(); - const updatedContent = DocumentDataContent({ - 'title': 'Dev final 2', - 'author': 'dev', - }); - final ref = DraftRef( - id: draft.metadata.id, - version: draft.metadata.version, - ); - - // When - await database.draftsDao.save(draft); - await database.draftsDao.updateContent(ref: ref, content: updatedContent); - - // Then - final entity = await database.draftsDao.query(ref: ref); - - expect(entity, isNotNull); - expect(entity?.content, updatedContent); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'replaces content for all matching ' - 'ids when ver is not specified', - () async { - // Given - final id = const Uuid().v7(); - final drafts = List.generate( - 5, - (index) => DraftFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: DraftRef(id: id, version: const Uuid().v7()), - ), - ), - ); - const updatedContent = DocumentDataContent({ - 'title': 'Dev final 2', - 'author': 'dev', - }); - final ref = DraftRef(id: id); - - // When - await database.draftsDao.saveAll(drafts); - await database.draftsDao.updateContent(ref: ref, content: updatedContent); - - // Then - final entities = await database.draftsDao.queryAll(); - - expect(entities, hasLength(drafts.length)); - expect( - entities.every((element) => element.content == updatedContent), - isTrue, - ); - }, - onPlatform: driftOnPlatforms, - ); - }); - - group('delete', () { - test( - 'inserting and deleting a draft makes the table empty', - () async { - // Given - final ref = DocumentRefFactory.draftRef(); - - final draft = DraftFactory.build( - metadata: DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: ref, - ), - ); - - // When - await database.draftsDao.save(draft); - await database.draftsDao.deleteWhere(ref: ref); - - // Then - final entities = await database.draftsDao.queryAll(); - expect(entities, isEmpty); - }, - onPlatform: driftOnPlatforms, - ); - }); - }); -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/proposals_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/proposals_dao_test.dart deleted file mode 100644 index 0314ea5c5fc7..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/proposals_dao_test.dart +++ /dev/null @@ -1,2150 +0,0 @@ -import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart' show Coin; -import 'package:catalyst_voices_dev/catalyst_voices_dev.dart'; -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; -import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; -import 'package:catalyst_voices_repositories/src/database/dao/proposals_dao.dart'; -import 'package:catalyst_voices_repositories/src/dto/proposal/proposal_submission_action_dto.dart'; -import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:uuid_plus/uuid_plus.dart'; - -import '../connection/test_connection.dart'; -import '../drift_test_platforms.dart'; - -void main() { - late DriftCatalystDatabase database; - - // ignore: unnecessary_lambdas - setUpAll(() { - DummyCatalystIdFactory.registerDummyKeyFactory(); - }); - - setUp(() async { - final connection = await buildTestConnection(); - database = DriftCatalystDatabase(connection); - }); - - tearDown(() async { - await database.close(); - }); - - group(DriftProposalsDao, () { - group( - 'watchCount', - () { - test( - 'returns correct total number of ' - 'proposals for empty filters', - () async { - // Given - final proposals = [ - _buildProposal(), - _buildProposal(), - ]; - const filters = ProposalsCountFilters(); - - // When - await database.documentsDao.saveAll(proposals); - - // Then - final count = await database.proposalsDao - .watchCount( - filters: filters, - ) - .first; - - expect(count.total, proposals.length); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'when two versions of same proposal ' - 'exists there are counted as one', - () async { - // Given - final ref = DocumentRefFactory.signedDocumentRef(); - final proposals = [ - _buildProposal(selfRef: ref), - _buildProposal(selfRef: ref.nextVersion().toSignedDocumentRef()), - ]; - const filters = ProposalsCountFilters(); - - // When - await database.documentsDao.saveAll(proposals); - - // Then - final count = await database.proposalsDao - .watchCount( - filters: filters, - ) - .first; - - expect(count.total, 1); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns one final proposal if final submission is found', - () async { - // Given - final ref = DocumentRefFactory.signedDocumentRef(); - final proposals = [ - _buildProposal(selfRef: ref), - _buildProposal(), - ]; - final actions = [ - _buildProposalAction( - action: ProposalSubmissionActionDto.aFinal, - proposalRef: ref, - ), - ]; - const filters = ProposalsCountFilters(); - - // When - await database.documentsDao.saveAll([...proposals, ...actions]); - - // Then - final count = await database.proposalsDao - .watchCount( - filters: filters, - ) - .first; - - expect(count.finals, 1); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns one final proposal when final submission is ' - 'latest action but old draft action exists', - () async { - // Given - final ref = DocumentRefFactory.signedDocumentRef(); - final proposals = [ - _buildProposal(selfRef: ref), - ]; - final actions = [ - _buildProposalAction( - selfRef: _buildRefAt(DateTime(2025, 04)), - action: ProposalSubmissionActionDto.aFinal, - proposalRef: ref, - ), - _buildProposalAction( - selfRef: _buildRefAt(DateTime(2025, 04, 2)), - action: ProposalSubmissionActionDto.draft, - proposalRef: ref, - ), - _buildProposalAction( - selfRef: _buildRefAt(DateTime(2025, 04, 8)), - action: ProposalSubmissionActionDto.aFinal, - proposalRef: ref, - ), - ]; - const filters = ProposalsCountFilters(); - - // When - await database.documentsDao.saveAll([...proposals, ...actions]); - - // Then - final count = await database.proposalsDao - .watchCount( - filters: filters, - ) - .first; - - expect(count.finals, 1); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns zero final proposal if latest ' - 'submission is draft', - () async { - // Given - final ref = DocumentRefFactory.signedDocumentRef(); - final proposals = [ - _buildProposal(selfRef: ref), - ]; - final actions = [ - _buildProposalAction( - selfRef: _buildRefAt(DateTime(2025, 04, 7)), - action: ProposalSubmissionActionDto.aFinal, - proposalRef: ref, - ), - _buildProposalAction( - selfRef: _buildRefAt(DateTime(2025, 04, 8)), - action: ProposalSubmissionActionDto.draft, - proposalRef: ref, - ), - ]; - const filters = ProposalsCountFilters(); - - // When - await database.documentsDao.saveAll([...proposals, ...actions]); - - // Then - final count = await database.proposalsDao - .watchCount( - filters: filters, - ) - .first; - - expect(count.finals, 0); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns two final proposal when each have ' - 'complex action history', - () async { - // Given - final proposalOneRef = DocumentRefFactory.signedDocumentRef(); - final proposalTwoRef = DocumentRefFactory.signedDocumentRef(); - final proposals = [ - _buildProposal(selfRef: proposalOneRef), - _buildProposal(selfRef: proposalTwoRef), - ]; - final actions = [ - _buildProposalAction( - selfRef: _buildRefAt(DateTime(2025, 04)), - action: ProposalSubmissionActionDto.draft, - proposalRef: proposalOneRef, - ), - _buildProposalAction( - selfRef: _buildRefAt(DateTime(2025, 04, 2)), - action: ProposalSubmissionActionDto.aFinal, - proposalRef: proposalOneRef, - ), - _buildProposalAction( - selfRef: _buildRefAt(DateTime(2025, 04, 7)), - action: ProposalSubmissionActionDto.hide, - proposalRef: proposalTwoRef, - ), - _buildProposalAction( - selfRef: _buildRefAt(DateTime(2025, 04, 8)), - action: ProposalSubmissionActionDto.aFinal, - proposalRef: proposalTwoRef, - ), - ]; - const filters = ProposalsCountFilters(); - - // When - await database.documentsDao.saveAll([...proposals, ...actions]); - - // Then - final count = await database.proposalsDao - .watchCount( - filters: filters, - ) - .first; - - expect(count.finals, 2); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns calculated drafts and finals count', - () async { - // Given - final proposalOneRef = DocumentRefFactory.signedDocumentRef(); - final proposalTwoRef = DocumentRefFactory.signedDocumentRef(); - final proposals = [ - _buildProposal(selfRef: proposalOneRef), - _buildProposal(selfRef: proposalTwoRef), - ]; - final actions = [ - _buildProposalAction( - action: ProposalSubmissionActionDto.aFinal, - proposalRef: proposalOneRef, - ), - _buildProposalAction( - action: ProposalSubmissionActionDto.draft, - proposalRef: proposalTwoRef, - ), - ]; - const filters = ProposalsCountFilters(); - - // When - await database.documentsDao.saveAll([...proposals, ...actions]); - - // Then - final count = await database.proposalsDao - .watchCount( - filters: filters, - ) - .first; - - expect(count.total, 2); - expect(count.drafts, 1); - expect(count.finals, 1); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns correct favorites count', - () async { - // Given - final proposalOneRef = DocumentRefFactory.signedDocumentRef(); - final proposalTwoRef = DocumentRefFactory.signedDocumentRef(); - final proposals = [ - _buildProposal(selfRef: proposalOneRef), - _buildProposal(selfRef: proposalTwoRef), - ]; - final favorites = [ - _buildProposalFavorite(proposalRef: proposalOneRef), - ]; - - const filters = ProposalsCountFilters(); - - // When - await database.documentsDao.saveAll(proposals); - for (final fav in favorites) { - await database.favoritesDao.save(fav); - } - - // Then - final count = await database.proposalsDao - .watchCount( - filters: filters, - ) - .first; - - expect(count.total, 2); - expect(count.favorites, 1); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns correct my count base on author', - () async { - // Given - final userId = DummyCatalystIdFactory.create(username: 'damian'); - final proposalOneRef = DocumentRefFactory.signedDocumentRef(); - final proposalTwoRef = DocumentRefFactory.signedDocumentRef(); - final proposals = [ - _buildProposal(selfRef: proposalOneRef), - _buildProposal(selfRef: proposalTwoRef, author: userId), - ]; - - final filters = ProposalsCountFilters(author: userId); - - // When - await database.documentsDao.saveAll(proposals); - - // Then - final count = await database.proposalsDao - .watchCount( - filters: filters, - ) - .first; - - expect(count.total, 2); - expect(count.my, 1); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns correct count when only author filter is on', - () async { - // Given - final userId = DummyCatalystIdFactory.create(username: 'damian'); - final proposalOneRef = DocumentRefFactory.signedDocumentRef(); - final proposalTwoRef = DocumentRefFactory.signedDocumentRef(); - final proposals = [ - _buildProposal(selfRef: proposalOneRef), - _buildProposal(selfRef: proposalTwoRef, author: userId), - ]; - final favorites = [ - _buildProposalFavorite(proposalRef: proposalOneRef), - ]; - final actions = [ - _buildProposalAction( - action: ProposalSubmissionActionDto.aFinal, - proposalRef: proposalTwoRef, - ), - ]; - - final filters = ProposalsCountFilters( - author: userId, - onlyAuthor: true, - ); - const expectedCount = ProposalsCount( - total: 1, - finals: 1, - my: 1, - myFinals: 1, - ); - - // When - await database.documentsDao.saveAll([...proposals, ...actions]); - - for (final fav in favorites) { - await database.favoritesDao.save(fav); - } - - // Then - final count = await database.proposalsDao - .watchCount( - filters: filters, - ) - .first; - - expect(count, expectedCount); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns correct count when category filter is on', - () async { - // Given - final userId = DummyCatalystIdFactory.create(username: 'damian'); - final categoryId = _getCategoryId(); - - final proposalOneRef = DocumentRefFactory.signedDocumentRef(); - final proposalTwoRef = DocumentRefFactory.signedDocumentRef(); - final proposals = [ - _buildProposal( - selfRef: proposalOneRef, - categoryId: _getCategoryId(index: 1), - ), - _buildProposal( - selfRef: proposalTwoRef, - author: userId, - categoryId: categoryId, - ), - ]; - final favorites = [ - _buildProposalFavorite(proposalRef: proposalOneRef), - ]; - final actions = [ - _buildProposalAction( - action: ProposalSubmissionActionDto.aFinal, - proposalRef: proposalTwoRef, - ), - ]; - - final filters = ProposalsCountFilters(category: categoryId); - const expectedCount = ProposalsCount( - total: 1, - finals: 1, - ); - - // When - await database.documentsDao.saveAll([...proposals, ...actions]); - - for (final fav in favorites) { - await database.favoritesDao.save(fav); - } - - // Then - final count = await database.proposalsDao - .watchCount( - filters: filters, - ) - .first; - - expect(count, expectedCount); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns correct count when search query is not empty', - () async { - // Given - final proposals = [ - _buildProposal(), - _buildProposal(title: 'Explore'), - _buildProposal(title: 'Not this one'), - ]; - - /* cSpell:disable */ - const filters = ProposalsCountFilters(searchQuery: 'Expl'); - /* cSpell:enable */ - const expectedCount = ProposalsCount( - total: 1, - drafts: 1, - ); - - // When - await database.documentsDao.saveAll(proposals); - - // Then - final count = await database.proposalsDao - .watchCount( - filters: filters, - ) - .first; - - expect(count, expectedCount); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'search is looking up author name in catalystId', - () async { - // Given - const authorName = 'Damian'; - final search = authorName.substring(0, 2); - final userId = DummyCatalystIdFactory.create(username: authorName); - - final proposals = [ - _buildProposal(contentAuthorName: 'Unknown'), - _buildProposal(author: userId), - _buildProposal(contentAuthorName: 'Other'), - ]; - - final filters = ProposalsCountFilters(searchQuery: search); - const expectedCount = ProposalsCount( - total: 1, - drafts: 1, - ); - - // When - await database.documentsDao.saveAll(proposals); - - // Then - final count = await database.proposalsDao - .watchCount( - filters: filters, - ) - .first; - - expect(count, expectedCount); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'search is looking up author name in content', - () async { - // Given - const authorName = 'Damian'; - final search = authorName.substring(0, 2); - - final proposals = [ - _buildProposal(contentAuthorName: 'Unknown'), - _buildProposal(contentAuthorName: authorName), - _buildProposal(contentAuthorName: 'Other'), - ]; - - final filters = ProposalsCountFilters(searchQuery: search); - const expectedCount = ProposalsCount( - total: 1, - drafts: 1, - ); - - // When - await database.documentsDao.saveAll(proposals); - - // Then - final count = await database.proposalsDao - .watchCount( - filters: filters, - ) - .first; - - expect(count, expectedCount); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns correctly counted proposals', - () async { - // Given - final one = DocumentRefFactory.signedDocumentRef(); - final two = one.nextVersion().toSignedDocumentRef(); - final three = two.nextVersion().toSignedDocumentRef(); - - final proposals = [ - _buildProposal(selfRef: one), - _buildProposal(selfRef: two), - _buildProposal(selfRef: three), - ]; - final actions = [ - _buildProposalAction( - selfRef: DocumentRefFactory.signedDocumentRef(), - action: ProposalSubmissionActionDto.aFinal, - proposalRef: two, - ), - ]; - const filters = ProposalsCountFilters(); - const expectedCount = ProposalsCount( - total: 1, - finals: 1, - ); - - // When - await database.documentsDao.saveAll([...proposals, ...actions]); - - // Then - final count = await database.proposalsDao - .watchCount( - filters: filters, - ) - .first; - - expect(count, expectedCount); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'hidden proposals are excluded from count', - () async { - // Given - final one = DocumentRefFactory.signedDocumentRef(); - final two = DocumentRefFactory.signedDocumentRef(); - - final proposals = [ - _buildProposal(selfRef: one), - _buildProposal(selfRef: two), - ]; - final actions = [ - _buildProposalAction( - selfRef: DocumentRefFactory.signedDocumentRef(), - action: ProposalSubmissionActionDto.hide, - proposalRef: two, - ), - ]; - const filters = ProposalsCountFilters(); - const expectedCount = ProposalsCount( - total: 1, - drafts: 1, - ); - - // When - await database.documentsDao.saveAll([...proposals, ...actions]); - - // Then - final count = await database.proposalsDao - .watchCount( - filters: filters, - ) - .first; - - expect(count, expectedCount); - }, - onPlatform: driftOnPlatforms, - ); - }, - ); - - group('queryProposalsPage', () { - test( - 'only newest version of proposal is returned', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - - final ref = _buildRefAt(DateTime(2025, 4, 7)); - final nextRef = _buildRefAt(DateTime(2025, 4, 8)).copyWith(id: ref.id); - final latestRef = _buildRefAt(DateTime(2025, 4, 9)).copyWith(id: ref.id); - - final differentRef = _buildRefAt(DateTime(2025, 4, 12)); - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposals = [ - _buildProposal(selfRef: ref, template: templateRef), - _buildProposal(selfRef: nextRef, template: templateRef), - _buildProposal(selfRef: latestRef, template: templateRef), - _buildProposal(selfRef: differentRef, template: templateRef), - ]; - const request = PageRequest(page: 0, size: 10); - const filters = ProposalsFilters(); - const order = UpdateDate(isAscending: true); - - final expectedRefs = [ - latestRef, - differentRef, - ]; - - // When - await database.documentsDao.saveAll([...templates, ...proposals]); - - // Then - final page = await database.proposalsDao.queryProposalsPage( - request: request, - filters: filters, - order: order, - ); - - expect(page.items.length, 2); - expect(page.items.length, page.total); - - final proposalsRefs = page.items - .map((e) => e.proposal) - .map((entity) => entity.ref) - .toList(); - - expect( - proposalsRefs, - expectedRefs, - ); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'proposals are split into pages correctly', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final now = DateTime(2024, 4, 9); - final proposals = List.generate(45, (index) { - return _buildProposal( - selfRef: _buildRefAt(now.subtract(Duration(days: index))), - template: templateRef, - ); - }); - const filters = ProposalsFilters(); - const order = UpdateDate(isAscending: true); - - // When - await database.documentsDao.saveAll([...templates, ...proposals]); - - // Then - const firstRequest = PageRequest(page: 0, size: 25); - final pageZero = await database.proposalsDao.queryProposalsPage( - request: firstRequest, - filters: filters, - order: order, - ); - - expect(pageZero.page, 0); - expect(pageZero.total, proposals.length); - expect(pageZero.items.length, firstRequest.size); - - const secondRequest = PageRequest(page: 1, size: 25); - - final pageOne = await database.proposalsDao.queryProposalsPage( - request: secondRequest, - filters: filters, - order: order, - ); - - expect(pageOne.page, 1); - expect(pageOne.total, proposals.length); - expect( - pageOne.items.length, - proposals.length - pageZero.items.length, - ); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'proposals category filter works as expected', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - final categoryId = _getCategoryId(); - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposals = [ - _buildProposal( - selfRef: _buildRefAt(DateTime(2025, 4)), - template: templateRef, - categoryId: categoryId, - ), - _buildProposal( - selfRef: _buildRefAt(DateTime(2025, 4, 2)), - template: templateRef, - categoryId: categoryId, - ), - _buildProposal( - selfRef: _buildRefAt(DateTime(2025, 4, 3)), - template: templateRef, - categoryId: categoryId, - ), - _buildProposal( - template: templateRef, - categoryId: _getCategoryId(index: 1), - ), - ]; - - final expectedRefs = proposals - .sublist(0, 3) - .map((proposal) => proposal.document.ref) - .toList(); - - final filters = ProposalsFilters(category: categoryId); - const order = UpdateDate(isAscending: true); - - // When - await database.documentsDao.saveAll([...templates, ...proposals]); - - // Then - const request = PageRequest(page: 0, size: 25); - final page = await database.proposalsDao.queryProposalsPage( - request: request, - filters: filters, - order: order, - ); - - expect(page.page, 0); - expect(page.total, 3); - expect(page.items.map((e) => e.proposal.ref), expectedRefs); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'final proposals filter works as expected', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposalRef1 = _buildRefAt(DateTime(2025, 4)); - final proposalRef2 = _buildRefAt(DateTime(2025, 4, 2)); - final proposalRef3 = _buildRefAt(DateTime(2025, 4, 3)); - - final proposals = [ - _buildProposal( - selfRef: proposalRef1, - template: templateRef, - ), - _buildProposal( - selfRef: proposalRef2, - template: templateRef, - ), - _buildProposal( - selfRef: proposalRef3, - template: templateRef, - ), - _buildProposal(template: templateRef), - ]; - - final actions = [ - _buildProposalAction( - action: ProposalSubmissionActionDto.aFinal, - proposalRef: proposalRef1, - ), - _buildProposalAction( - action: ProposalSubmissionActionDto.aFinal, - proposalRef: proposalRef2, - ), - ]; - - final expectedRefs = [ - proposalRef1, - proposalRef2, - ]; - - const filters = ProposalsFilters(type: ProposalsFilterType.finals); - const order = UpdateDate(isAscending: true); - - // When - await database.documentsDao.saveAll([ - ...templates, - ...proposals, - ...actions, - ]); - - // Then - const request = PageRequest(page: 0, size: 25); - final page = await database.proposalsDao.queryProposalsPage( - request: request, - filters: filters, - order: order, - ); - - expect(page.page, 0); - expect(page.total, 2); - expect(page.items.map((e) => e.proposal.ref), expectedRefs); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'myFinals proposals filter works as expected', - () async { - // Given - final user1 = DummyCatalystIdFactory.create( - role0KeyBytes: base64UrlNoPadDecode('aovqiF+2wmrgcNDaPVsj1Z4Nwjmy9W0hS6jq3rRY5Mo='), - username: 'user1', - ); - final user2 = DummyCatalystIdFactory.create( - role0KeyBytes: base64UrlNoPadDecode('wHxjq8XGj5MwgRbCqi4tTY/5qvmBDk4ld1Z/AQ1chD8='), - username: 'user2', - ); - final templateRef = DocumentRefFactory.signedDocumentRef(); - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposalRef1 = _buildRefAt(DateTime(2025, 4)); - final proposalRef2 = _buildRefAt(DateTime(2025, 4, 2)); - final proposalRef3 = _buildRefAt(DateTime(2025, 4, 3)); - - final proposals = [ - _buildProposal( - selfRef: proposalRef1, - template: templateRef, - author: user1, - ), - _buildProposal( - selfRef: proposalRef2, - template: templateRef, - author: user2, - ), - _buildProposal( - selfRef: proposalRef3, - template: templateRef, - author: user2, - ), - _buildProposal(template: templateRef), - ]; - - final actions = [ - _buildProposalAction( - action: ProposalSubmissionActionDto.aFinal, - proposalRef: proposalRef1, - ), - _buildProposalAction( - action: ProposalSubmissionActionDto.aFinal, - proposalRef: proposalRef2, - ), - ]; - - final expectedRefs = [ - proposalRef1, - ]; - - final filters = ProposalsFilters( - type: ProposalsFilterType.myFinals, - author: user1, - ); - - // When - await database.documentsDao.saveAll([ - ...templates, - ...proposals, - ...actions, - ]); - - // Then - const request = PageRequest(page: 0, size: 25); - final page = await database.proposalsDao.queryProposalsPage( - request: request, - filters: filters, - order: const Alphabetical(), - ); - - expect(page.page, 0); - expect(page.total, 1); - expect(page.items.map((e) => e.proposal.ref), expectedRefs); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'final proposals is one with latest action as final', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposalRef1 = _buildRefAt(DateTime(2025, 4)); - final proposalRef2 = _buildRefAt(DateTime(2025, 4, 2)); - final proposalRef3 = _buildRefAt(DateTime(2025, 4, 3)); - - final proposals = [ - _buildProposal( - selfRef: proposalRef1, - template: templateRef, - ), - _buildProposal( - selfRef: proposalRef2, - template: templateRef, - ), - _buildProposal( - selfRef: proposalRef3, - template: templateRef, - ), - _buildProposal(template: templateRef), - ]; - - final actions = [ - _buildProposalAction( - selfRef: _buildRefAt(DateTime(2025, 4, 5)), - action: ProposalSubmissionActionDto.aFinal, - proposalRef: proposalRef1, - ), - _buildProposalAction( - selfRef: _buildRefAt(DateTime(2025, 4)), - action: ProposalSubmissionActionDto.draft, - proposalRef: proposalRef1, - ), - ]; - - final expectedRefs = [ - proposalRef1, - ]; - - const filters = ProposalsFilters(type: ProposalsFilterType.finals); - const order = UpdateDate(isAscending: true); - - // When - await database.documentsDao.saveAll([ - ...templates, - ...proposals, - ...actions, - ]); - - // Then - const request = PageRequest(page: 0, size: 25); - final page = await database.proposalsDao.queryProposalsPage( - request: request, - filters: filters, - order: order, - ); - - expect(page.page, 0); - expect(page.total, 1); - expect(page.items.map((e) => e.proposal.ref), expectedRefs); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'JoinedProposal is build correctly ', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposalRef1 = _buildRefAt(DateTime(2025, 4)); - final proposalRef2 = _buildRefAt(DateTime(2025, 4, 2)).copyWith(id: proposalRef1.id); - final proposalRef3 = _buildRefAt(DateTime(2025, 4, 3)).copyWith(id: proposalRef1.id); - - final proposals = [ - _buildProposal( - selfRef: proposalRef1, - template: templateRef, - ), - _buildProposal( - selfRef: proposalRef2, - template: templateRef, - ), - _buildProposal( - selfRef: proposalRef3, - template: templateRef, - ), - ]; - - final actions = [ - _buildProposalAction( - selfRef: _buildRefAt(DateTime(2025, 4, 5)), - action: ProposalSubmissionActionDto.aFinal, - proposalRef: proposalRef2, - ), - _buildProposalAction( - selfRef: _buildRefAt(DateTime(2025, 4)), - action: ProposalSubmissionActionDto.draft, - proposalRef: proposalRef1, - ), - ]; - - final comments = [ - _buildProposalComment(proposalRef: proposalRef1), - _buildProposalComment(proposalRef: proposalRef2), - _buildProposalComment(proposalRef: proposalRef2), - _buildProposalComment(proposalRef: proposalRef3), - ]; - - const filters = ProposalsFilters(); - const order = UpdateDate(isAscending: true); - - // When - await database.documentsDao.saveAll([ - ...templates, - ...proposals, - ...actions, - ...comments, - ]); - - // Then - const request = PageRequest(page: 0, size: 25); - final page = await database.proposalsDao.queryProposalsPage( - request: request, - filters: filters, - order: order, - ); - - expect(page.page, 0); - expect(page.total, 1); - - final joinedProposal = page.items.single; - - expect(joinedProposal.proposal, proposals[1].document); - expect(joinedProposal.template, templates[0].document); - expect(joinedProposal.action, actions[0].document); - expect(joinedProposal.commentsCount, 2); - expect( - joinedProposal.versions, - proposals.map((e) => e.document.ref.version).toList().reversed, - ); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'search query is looking up catalystId and proposal content ', - () async { - // Given - const authorName = 'Damian'; - final searchQuery = authorName.substring(0, 3); - - final templateRef = DocumentRefFactory.signedDocumentRef(); - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposals = [ - _buildProposal( - template: templateRef, - author: DummyCatalystIdFactory.create(username: authorName), - title: '11', - ), - _buildProposal( - template: templateRef, - contentAuthorName: authorName, - title: '22', - ), - _buildProposal( - template: templateRef, - contentAuthorName: 'Different one', - title: 'Test', - ), - ]; - - final expectedRefs = [ - proposals[0].document.metadata.selfRef, - proposals[1].document.metadata.selfRef, - ]; - - final actions = []; - final comments = []; - - final filters = ProposalsFilters(searchQuery: searchQuery); - const order = UpdateDate(isAscending: true); - - // When - await database.documentsDao.saveAll([ - ...templates, - ...proposals, - ...actions, - ...comments, - ]); - - // Then - const request = PageRequest(page: 0, size: 25); - final page = await database.proposalsDao.queryProposalsPage( - request: request, - filters: filters, - order: order, - ); - - expect(page.page, 0); - expect(page.total, 2); - - final refs = page.items.map((e) => e.proposal.metadata.selfRef).toList(); - - expect(refs, hasLength(expectedRefs.length)); - expect(refs, containsAll(expectedRefs)); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'hidden proposals are filtered out when pointing to older version', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - final proposalRef = DocumentRefFactory.signedDocumentRef(); - final nextProposalRef = proposalRef.nextVersion().toSignedDocumentRef(); - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposals = [ - _buildProposal( - selfRef: proposalRef, - template: templateRef, - ), - _buildProposal( - selfRef: nextProposalRef, - template: templateRef, - ), - ]; - - const expectedRefs = []; - - final actions = [ - _buildProposalAction( - selfRef: _buildRefAt(DateTime(2025, 5, 2)), - action: ProposalSubmissionActionDto.aFinal, - proposalRef: proposalRef, - ), - _buildProposalAction( - selfRef: _buildRefAt(DateTime(2025, 5, 20)), - action: ProposalSubmissionActionDto.hide, - proposalRef: proposalRef, - ), - ]; - final comments = []; - - const filters = ProposalsFilters(); - const order = UpdateDate(isAscending: true); - - // When - await database.documentsDao.saveAll([ - ...templates, - ...proposals, - ...actions, - ...comments, - ]); - - // Then - const request = PageRequest(page: 0, size: 25); - final page = await database.proposalsDao.queryProposalsPage( - request: request, - filters: filters, - order: order, - ); - - expect(page.page, 0); - expect(page.total, expectedRefs.length); - - final refs = page.items.map((e) => e.proposal.metadata.selfRef).toList(); - - expect(refs, hasLength(expectedRefs.length)); - expect(refs, containsAll(expectedRefs)); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'order alphabetical works against title', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - const titles = [ - 'Abc', - 'Bcd', - 'cde', - ]; - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposals = titles.map((title) { - return _buildProposal( - selfRef: DocumentRefFactory.signedDocumentRef(), - template: templateRef, - title: title, - ); - }).shuffled(); - - final actions = []; - final comments = []; - - const filters = ProposalsFilters(); - const order = Alphabetical(); - - // When - await database.documentsDao.saveAll([ - ...templates, - ...proposals, - ...actions, - ...comments, - ]); - - // Then - const request = PageRequest(page: 0, size: 25); - final page = await database.proposalsDao.queryProposalsPage( - request: request, - filters: filters, - order: order, - ); - - expect(page.page, 0); - - final proposalsTitles = page.items.map((e) => e.proposal.content.title).toList(); - - expect(proposalsTitles, containsAllInOrder(titles)); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'order budget asc works against content path', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - const budgets = [ - Coin.fromWholeAda(100000), - Coin.fromWholeAda(199999), - Coin.fromWholeAda(200000), - ]; - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposals = budgets.map((requestedFund) { - return _buildProposal( - selfRef: DocumentRefFactory.signedDocumentRef(), - template: templateRef, - requestedFunds: requestedFund, - ); - }).shuffled(); - - final actions = []; - final comments = []; - - const filters = ProposalsFilters(); - const order = Budget(isAscending: true); - - // When - await database.documentsDao.saveAll([ - ...templates, - ...proposals, - ...actions, - ...comments, - ]); - - // Then - const request = PageRequest(page: 0, size: 25); - final page = await database.proposalsDao.queryProposalsPage( - request: request, - filters: filters, - order: order, - ); - - expect(page.page, 0); - - final proposalsBudgets = page.items - .map((e) => e.proposal.content.requestedFunds) - .toList(); - - expect(proposalsBudgets, containsAllInOrder(budgets)); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'order budget desc works against content path', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - const budgets = [ - Coin.fromWholeAda(200000), - Coin.fromWholeAda(199999), - Coin.fromWholeAda(100000), - ]; - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposals = budgets.map((requestedFund) { - return _buildProposal( - selfRef: DocumentRefFactory.signedDocumentRef(), - template: templateRef, - requestedFunds: requestedFund, - ); - }).shuffled(); - - final actions = []; - final comments = []; - - const filters = ProposalsFilters(); - const order = Budget(isAscending: false); - - // When - await database.documentsDao.saveAll([ - ...templates, - ...proposals, - ...actions, - ...comments, - ]); - - // Then - const request = PageRequest(page: 0, size: 25); - final page = await database.proposalsDao.queryProposalsPage( - request: request, - filters: filters, - order: order, - ); - - expect(page.page, 0); - - final proposalsBudgets = page.items - .map((e) => e.proposal.content.requestedFunds) - .toList(); - - expect(proposalsBudgets, containsAllInOrder(budgets)); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'order updateDate asc works against content path', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - final dates = [ - DateTime.utc(2025, 5, 10), - DateTime.utc(2025, 5, 20), - DateTime.utc(2025, 5, 29), - ]; - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposals = dates.map((date) { - return _buildProposal( - selfRef: _buildRefAt(date), - template: templateRef, - ); - }).shuffled(); - - final actions = []; - final comments = []; - - const filters = ProposalsFilters(); - const order = UpdateDate(isAscending: true); - - // When - await database.documentsDao.saveAll([ - ...templates, - ...proposals, - ...actions, - ...comments, - ]); - - // Then - const request = PageRequest(page: 0, size: 25); - final page = await database.proposalsDao.queryProposalsPage( - request: request, - filters: filters, - order: order, - ); - - expect(page.page, 0); - - final proposalsDates = page.items - .map((e) => UuidHiLo(high: e.proposal.verHi, low: e.proposal.verLo).dateTime) - .toList(); - - expect(proposalsDates, containsAllInOrder(dates)); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'order updateDate desc works against content path', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - final dates = [ - DateTime.utc(2025, 5, 29), - DateTime.utc(2025, 5, 20), - DateTime.utc(2025, 5, 10), - ]; - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposals = dates.map((date) { - return _buildProposal( - selfRef: _buildRefAt(date), - template: templateRef, - ); - }).shuffled(); - - final actions = []; - final comments = []; - - const filters = ProposalsFilters(); - const order = UpdateDate(isAscending: false); - - // When - await database.documentsDao.saveAll([ - ...templates, - ...proposals, - ...actions, - ...comments, - ]); - - // Then - const request = PageRequest(page: 0, size: 25); - final page = await database.proposalsDao.queryProposalsPage( - request: request, - filters: filters, - order: order, - ); - - expect(page.page, 0); - - final proposalsDates = page.items - .map((e) => UuidHiLo(high: e.proposal.verHi, low: e.proposal.verLo).dateTime) - .toList(); - - expect(proposalsDates, containsAllInOrder(dates)); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'latest version value is one ordered against', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - final proposalRef = SignedDocumentRef.first(_buildUuidAt(DateTime.utc(2025, 5, 10))); - final latestProposalRef = proposalRef.copyWith( - version: Optional(_buildUuidAt(DateTime.utc(2025, 5, 29))), - ); - - const expectedBudgets = [ - Coin.fromWholeAda(30000), - Coin.fromWholeAda(2000), - ]; - final refsBudgets = { - proposalRef: const Coin.fromWholeAda(10000), - latestProposalRef: expectedBudgets[0], - DocumentRefFactory.signedDocumentRef(): expectedBudgets[1], - }; - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposals = refsBudgets.entries.map( - (entity) { - return _buildProposal( - selfRef: entity.key, - template: templateRef, - requestedFunds: entity.value, - ); - }, - ).shuffled(); - - final actions = []; - final comments = []; - - const filters = ProposalsFilters(); - const order = Budget(isAscending: false); - - // When - await database.documentsDao.saveAll([ - ...templates, - ...proposals, - ...actions, - ...comments, - ]); - - // Then - const request = PageRequest(page: 0, size: 25); - final page = await database.proposalsDao.queryProposalsPage( - request: request, - filters: filters, - order: order, - ); - - expect(page.page, 0); - - final proposalsBudgets = page.items - .map((e) => e.proposal.content.requestedFunds) - .toList(); - - expect(proposalsBudgets, containsAllInOrder(expectedBudgets)); - }, - onPlatform: driftOnPlatforms, - ); - }); - group('queryProposals', () { - test( - 'returns only newest version of each proposal', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - - final ref = _buildRefAt(DateTime(2025, 4, 7)); - final nextRef = _buildRefAt(DateTime(2025, 4, 8)).copyWith(id: ref.id); - final latestRef = _buildRefAt(DateTime(2025, 4, 9)).copyWith(id: ref.id); - - final differentRef = _buildRefAt(DateTime(2025, 4, 12)); - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposals = [ - _buildProposal(selfRef: ref, template: templateRef), - _buildProposal(selfRef: nextRef, template: templateRef), - _buildProposal(selfRef: latestRef, template: templateRef), - _buildProposal(selfRef: differentRef, template: templateRef), - ]; - - final expectedRefs = [ - latestRef, - differentRef, - ]; - - // When - await database.documentsDao.saveAll([...templates, ...proposals]); - - // Then - final result = await database.proposalsDao.queryProposals( - filters: const ProposalsFilters(), - ); - - expect(result.length, 2); - expect( - result.map((e) => e.proposal.ref), - expectedRefs, - ); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'filters by category when categoryRef is provided', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposals = [ - _buildProposal( - selfRef: _buildRefAt(DateTime(2025, 4)), - template: templateRef, - ), - _buildProposal( - selfRef: _buildRefAt(DateTime(2025, 4, 2)), - template: templateRef, - categoryId: _getCategoryId(index: 1), - ), - _buildProposal( - selfRef: _buildRefAt(DateTime(2025, 4, 3)), - template: templateRef, - categoryId: _getCategoryId(index: 1), - ), - ]; - - final expectedRefs = proposals - .where( - (p) => p.document.metadata.categoryId == _getCategoryId(index: 1), - ) - .map((proposal) => proposal.document.ref) - .toList(); - - // When - await database.documentsDao.saveAll([...templates, ...proposals]); - - // Then - final result = await database.proposalsDao.queryProposals( - categoryRef: _getCategoryId(index: 1), - filters: const ProposalsFilters(), - ); - - expect(result.length, 2); - expect( - result.map((e) => e.proposal.ref).toList(), - expectedRefs, - ); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'filters final proposals correctly', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposalRef1 = _buildRefAt(DateTime(2025, 4)); - final proposalRef2 = _buildRefAt(DateTime(2025, 4, 2)); - final proposalRef3 = _buildRefAt(DateTime(2025, 4, 3)); - - final proposals = [ - _buildProposal( - selfRef: proposalRef1, - template: templateRef, - ), - _buildProposal( - selfRef: proposalRef2, - template: templateRef, - ), - _buildProposal( - selfRef: proposalRef3, - template: templateRef, - ), - ]; - - final actions = [ - _buildProposalAction( - action: ProposalSubmissionActionDto.aFinal, - proposalRef: proposalRef1, - ), - _buildProposalAction( - action: ProposalSubmissionActionDto.aFinal, - proposalRef: proposalRef2, - ), - ]; - - final expectedRefs = [ - proposalRef1, - proposalRef2, - ]; - - // When - await database.documentsDao.saveAll([ - ...templates, - ...proposals, - ...actions, - ]); - - // Then - final result = await database.proposalsDao.queryProposals( - filters: const ProposalsFilters(type: ProposalsFilterType.finals), - ); - - expect(result.length, 2); - expect( - result.map((e) => e.proposal.ref), - expectedRefs, - ); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'returns correct JoinedProposal structure', - () async { - // Given - final templateRef = DocumentRefFactory.signedDocumentRef(); - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final baseTime = DateTime(2025, 4); - final proposalRef1 = _buildRefAt(baseTime); - final proposalRef2 = _buildRefAt( - baseTime.add(const Duration(days: 1)), - ).copyWith(id: proposalRef1.id); - final proposalRef3 = _buildRefAt( - baseTime.add(const Duration(days: 2)), - ).copyWith(id: proposalRef1.id); - - final proposals = [ - _buildProposal( - selfRef: proposalRef1, - template: templateRef, - ), - _buildProposal( - selfRef: proposalRef2, - template: templateRef, - ), - _buildProposal( - selfRef: proposalRef3, - template: templateRef, - ), - ]; - - final actionTime = baseTime.add(const Duration(days: 3)); - final actions = [ - _buildProposalAction( - selfRef: _buildRefAt(actionTime), - action: ProposalSubmissionActionDto.aFinal, - proposalRef: proposalRef2, - ), - ]; - - final comments = [ - _buildProposalComment(proposalRef: proposalRef1), - _buildProposalComment(proposalRef: proposalRef2), - _buildProposalComment(proposalRef: proposalRef2), - _buildProposalComment(proposalRef: proposalRef3), - ]; - - // When - await database.documentsDao.saveAll([ - ...templates, - ...proposals, - ...actions, - ...comments, - ]); - - // Then - final result = await database.proposalsDao.queryProposals( - filters: const ProposalsFilters(), - ); - - expect(result.length, 1); - - final joinedProposal = result.single; - - // Since there's a final action pointing to proposalRef2, - // that should be the effective proposal version - expect(joinedProposal.proposal, proposals[1].document); - expect(joinedProposal.template, templates[0].document); - expect(joinedProposal.action, actions[0].document); - expect( - joinedProposal.commentsCount, - 2, - ); - expect( - joinedProposal.versions, - proposals.map((e) => e.document.ref.version).toList().reversed, - ); - - expect(joinedProposal.proposal.ref, proposalRef2); - expect(joinedProposal.action?.metadata.ref, proposalRef2); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'order alphabetical works case-insensitively with null titles', - () async { - // Given - final templateRef = SignedDocumentRef.generateFirstRef(); - const titles = [ - 'ABC', - 'bcd', - null, - 'Xyz', - 'aabc', //cspell:disable-line - ]; - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposals = titles.map((title) { - return _buildProposal( - selfRef: SignedDocumentRef.generateFirstRef(), - template: templateRef, - title: title, - ); - }).shuffled(); - - final actions = []; - final comments = []; - - const filters = ProposalsFilters(); - const order = Alphabetical(); - - // When - await database.documentsDao.saveAll([ - ...templates, - ...proposals, - ...actions, - ...comments, - ]); - - // Then - const request = PageRequest(page: 0, size: 25); - final page = await database.proposalsDao.queryProposalsPage( - request: request, - filters: filters, - order: order, - ); - - expect(page.page, 0); - - final proposalsTitles = page.items.map((e) => e.proposal.content.title).toList(); - - final expectedOrder = [ - 'aabc', //cspell:disable-line - 'ABC', - 'bcd', - 'Xyz', - null, - ]; - - expect(proposalsTitles, containsAllInOrder(expectedOrder)); - }, - onPlatform: driftOnPlatforms, - ); - - test( - 'order alphabetical works case-insensitively', - () async { - // Given - final templateRef = SignedDocumentRef.generateFirstRef(); - const titles = [ - 'Bravo', - 'Lima', - 'Test', - 'alpha', - 'beta', - 'leet', - 'tango', - ]; - - final templates = [ - _buildProposalTemplate(selfRef: templateRef), - ]; - - final proposals = titles.map((title) { - return _buildProposal( - selfRef: SignedDocumentRef.generateFirstRef(), - template: templateRef, - title: title, - ); - }).shuffled(); - - final actions = []; - final comments = []; - - const filters = ProposalsFilters(); - const order = Alphabetical(); - - // When - await database.documentsDao.saveAll([ - ...templates, - ...proposals, - ...actions, - ...comments, - ]); - - // Then - const request = PageRequest(page: 0, size: 25); - final page = await database.proposalsDao.queryProposalsPage( - request: request, - filters: filters, - order: order, - ); - - expect(page.page, 0); - - final proposalsTitles = page.items.map((e) => e.proposal.content.title).toList(); - - final expectedOrder = [ - 'alpha', - 'beta', - 'Bravo', - 'leet', - 'Lima', - 'tango', - 'Test', - ]; - - expect(proposalsTitles, containsAllInOrder(expectedOrder)); - }, - onPlatform: driftOnPlatforms, - ); - }); - }); -} - -final _dummyCategoriesCache = {}; - -DocumentEntityWithMetadata _buildProposal({ - SignedDocumentRef? selfRef, - SignedDocumentRef? template, - String? title, - CatalystId? author, - String? contentAuthorName, - SignedDocumentRef? categoryId, - Coin? requestedFunds, -}) { - final metadata = DocumentDataMetadata( - type: DocumentType.proposalDocument, - selfRef: selfRef ?? DocumentRefFactory.signedDocumentRef(), - template: template ?? DocumentRefFactory.signedDocumentRef(), - authors: [ - if (author != null) author, - ], - categoryId: categoryId ?? _getCategoryId(), - ); - final content = DocumentDataContent({ - if (title != null || contentAuthorName != null) - 'setup': { - if (contentAuthorName != null) - 'proposer': { - 'applicant': contentAuthorName, - }, - if (title != null) - 'title': { - 'title': title, - }, - }, - if (requestedFunds != null) - 'summary': { - 'budget': { - 'requestedFunds': requestedFunds.ada.toInt(), - }, - }, - }); - - final document = DocumentFactory.build( - content: content, - metadata: metadata, - ); - - final metadataEntities = [ - if (title != null) - DocumentMetadataFactory.build( - ver: metadata.selfRef.version, - fieldKey: DocumentMetadataFieldKey.title, - fieldValue: title, - ), - ]; - - return (document: document, metadata: metadataEntities); -} - -DocumentEntityWithMetadata _buildProposalAction({ - DocumentRef? selfRef, - required ProposalSubmissionActionDto action, - required DocumentRef proposalRef, -}) { - final metadata = DocumentDataMetadata( - type: DocumentType.proposalActionDocument, - selfRef: selfRef ?? DocumentRefFactory.signedDocumentRef(), - ref: proposalRef, - ); - final dto = ProposalSubmissionActionDocumentDto(action: action); - final content = DocumentDataContent(dto.toJson()); - - final document = DocumentFactory.build( - content: content, - metadata: metadata, - ); - - const metadataEntities = []; - - return (document: document, metadata: metadataEntities); -} - -DocumentEntityWithMetadata _buildProposalComment({ - SignedDocumentRef? selfRef, - required DocumentRef proposalRef, -}) { - final metadata = DocumentDataMetadata( - type: DocumentType.commentDocument, - selfRef: selfRef ?? DocumentRefFactory.signedDocumentRef(), - ref: proposalRef, - ); - const content = DocumentDataContent({}); - - final document = DocumentFactory.build( - content: content, - metadata: metadata, - ); - - final metadataEntities = []; - - return (document: document, metadata: metadataEntities); -} - -DocumentFavoriteEntity _buildProposalFavorite({ - required DocumentRef proposalRef, -}) { - final hiLo = UuidHiLo.from(proposalRef.id); - return DocumentFavoriteEntity( - idHi: hiLo.high, - idLo: hiLo.low, - isFavorite: true, - type: DocumentType.proposalDocument, - ); -} - -DocumentEntityWithMetadata _buildProposalTemplate({ - SignedDocumentRef? selfRef, -}) { - final metadata = DocumentDataMetadata( - type: DocumentType.proposalTemplate, - selfRef: selfRef ?? DocumentRefFactory.signedDocumentRef(), - ); - const content = DocumentDataContent({}); - - final document = DocumentFactory.build( - content: content, - metadata: metadata, - ); - - final metadataEntities = []; - - return (document: document, metadata: metadataEntities); -} - -SignedDocumentRef _buildRefAt(DateTime dateTime) { - return SignedDocumentRef.first(_buildUuidAt(dateTime)); -} - -String _buildUuidAt(DateTime dateTime) { - final config = V7Options(dateTime.millisecondsSinceEpoch, null); - return const Uuid().v7(config: config); -} - -SignedDocumentRef _getCategoryId({ - int index = 0, -}) { - return activeConstantDocumentRefs.elementAtOrNull(index)?.category ?? - _dummyCategoriesCache.putIfAbsent(index, DocumentRefFactory.signedDocumentRef); -} - -extension on DocumentEntity { - SignedDocumentRef get ref { - return SignedDocumentRef( - id: UuidHiLo(high: idHi, low: idLo).uuid, - version: UuidHiLo(high: verHi, low: verLo).uuid, - ); - } -} 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 71a07956a167..df405a577eb7 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 @@ -6,16 +6,14 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; import 'package:catalyst_voices_repositories/src/database/dao/proposals_v2_dao.dart'; import 'package:catalyst_voices_repositories/src/database/model/document_with_authors_entity.dart'; -import 'package:catalyst_voices_repositories/src/database/table/document_authors.drift.dart'; import 'package:catalyst_voices_repositories/src/database/table/documents_local_metadata.drift.dart'; -import 'package:catalyst_voices_repositories/src/database/table/documents_v2.drift.dart'; import 'package:catalyst_voices_repositories/src/dto/proposal/proposal_submission_action_dto.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:uuid_plus/uuid_plus.dart'; +import '../../utils/document_with_authors_factory.dart'; import '../connection/test_connection.dart'; void main() { @@ -4979,11 +4977,7 @@ void main() { }); } -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)); -} +String _buildUuidV7At(DateTime dateTime) => DocumentRefFactory.uuidV7At(dateTime); CatalystId _createTestAuthor({ String? name, @@ -5033,16 +5027,12 @@ DocumentWithAuthorsEntity _createTestDocumentEntity({ String? templateId, String? templateVer, }) { - id ??= DocumentRefFactory.randomUuidV7(); - ver ??= id; - authors ??= ''; - - final docEntity = DocumentEntityV2( + return DocumentWithAuthorsFactory.create( id: id, ver: ver, - content: DocumentDataContent(contentData), - createdAt: createdAt ?? ver.tryDateTime ?? DateTime.now(), + contentData: contentData, type: type, + createdAt: createdAt, authors: authors, categoryId: categoryId, categoryVer: categoryVer, @@ -5054,24 +5044,6 @@ DocumentWithAuthorsEntity _createTestDocumentEntity({ templateId: templateId, templateVer: templateVer, ); - - final authorsEntities = authors - .split(',') - .where((element) => element.trim().isNotEmpty) - .map(CatalystId.tryParse) - .nonNulls - .map( - (e) => DocumentAuthorEntity( - documentId: docEntity.id, - documentVer: docEntity.ver, - authorId: e.toUri().toString(), - authorIdSignificant: e.toSignificant().toUri().toString(), - authorUsername: e.username, - ), - ) - .toList(); - - return DocumentWithAuthorsEntity(docEntity, authorsEntities); } int _seedRole0KeySeedGetter(String name) => 0; diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/query/jsonb_expressions_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/query/jsonb_expressions_test.dart deleted file mode 100644 index 2144f819ba96..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/query/jsonb_expressions_test.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_repositories/src/database/query/jsonb_expressions.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group(JsonBExpressions, () { - test('generateSqlForJsonQuery handles simple paths correctly', () { - const handler = NodeId('user.name'); - final sql = JsonBExpressions.generateSqlForJsonQuery( - jsonContent: 'content', - nodeId: handler, - searchValue: 'John', - ); - - expect(sql, contains(r"json_extract(content, '$.user.name')")); - expect(sql, contains("LIKE '%John%'")); - }); - - test('generateSqlForJsonQuery handles exact match correctly', () { - const handler = NodeId('user.id'); - final sql = JsonBExpressions.generateSqlForJsonQuery( - jsonContent: 'content', - nodeId: handler, - searchValue: '123', - useExactMatch: true, - ); - - expect(sql, contains(r"json_extract(content, '$.user.id') = '123'")); - }); - - test('generateSqlForJsonQuery handles wildcard array correctly', () { - const handler = NodeId('items.*'); - final sql = JsonBExpressions.generateSqlForJsonQuery( - jsonContent: 'content', - nodeId: handler, - searchValue: 'test', - ); - - expect(sql, contains(r"SELECT 1 FROM json_tree(content, '$.items')")); - expect(sql, contains("WHERE json_tree.value LIKE '%test%'")); - }); - - test('generateSqlForJsonQuery handles wildcard with field correctly', () { - const handler = NodeId('items.*.name'); - final sql = JsonBExpressions.generateSqlForJsonQuery( - jsonContent: 'content', - nodeId: handler, - searchValue: 'test', - ); - - expect(sql, contains(r"SELECT 1 FROM json_each(json_extract(content, '$.items'))")); - expect(sql, contains(r"WHERE json_extract(value, '$.name') LIKE '%test%'")); - }); - }); -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/document/document_repository_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/document/document_repository_test.dart index 104c71096fcb..df3534b079cd 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/document/document_repository_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/document/document_repository_test.dart @@ -23,7 +23,6 @@ void main() { late DraftDataSource draftsSource; late SignedDocumentDataSource localDocuments; late DocumentDataRemoteSource remoteDocuments; - late DocumentFavoriteSource favoriteDocuments; setUp(() async { final connection = await buildTestConnection(); @@ -32,14 +31,12 @@ void main() { draftsSource = DatabaseDraftsDataSource(database); localDocuments = DatabaseDocumentsDataSource(database, const CatalystProfiler.noop()); remoteDocuments = _MockDocumentDataRemoteSource(); - favoriteDocuments = DatabaseDocumentFavoriteSource(database); repository = DocumentRepositoryImpl( database, draftsSource, localDocuments, remoteDocuments, - favoriteDocuments, ); }); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/utils/document_with_authors_factory.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/utils/document_with_authors_factory.dart new file mode 100644 index 000000000000..1ca52200443a --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/utils/document_with_authors_factory.dart @@ -0,0 +1,68 @@ +import 'package:catalyst_voices_dev/catalyst_voices_dev.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/src/database/model/document_with_authors_entity.dart'; +import 'package:catalyst_voices_repositories/src/database/table/document_authors.drift.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_v2.drift.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; + +final class DocumentWithAuthorsFactory { + DocumentWithAuthorsFactory._(); + + static DocumentWithAuthorsEntity create({ + String? id, + String? ver, + Map contentData = const {}, + DocumentType type = DocumentType.proposalDocument, + DateTime? createdAt, + String? authors, + String? categoryId, + String? categoryVer, + String? refId, + String? refVer, + String? replyId, + String? replyVer, + String? section, + String? templateId, + String? templateVer, + }) { + id ??= DocumentRefFactory.randomUuidV7(); + ver ??= id; + authors ??= ''; + + final docEntity = DocumentEntityV2( + id: id, + ver: ver, + content: DocumentDataContent(contentData), + createdAt: createdAt ?? ver.tryDateTime ?? DateTime.now(), + type: type, + authors: authors, + categoryId: categoryId, + categoryVer: categoryVer, + refId: refId, + refVer: refVer, + replyId: replyId, + replyVer: replyVer, + section: section, + templateId: templateId, + templateVer: templateVer, + ); + + final authorsEntities = authors + .split(',') + .where((element) => element.trim().isNotEmpty) + .map(CatalystId.tryParse) + .nonNulls + .map( + (e) => DocumentAuthorEntity( + documentId: docEntity.id, + documentVer: docEntity.ver, + authorId: e.toUri().toString(), + authorIdSignificant: e.toSignificant().toUri().toString(), + authorUsername: e.username, + ), + ) + .toList(); + + return DocumentWithAuthorsEntity(docEntity, authorsEntities); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/documents/documents_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/documents/documents_service.dart index 95408a343777..157d04fb0f36 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/documents/documents_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/documents/documents_service.dart @@ -21,6 +21,8 @@ abstract interface class DocumentsService { /// if [keepLocalDrafts] is true local drafts and their templates will be kept. Future clear({bool keepLocalDrafts}); + Future isFavorite(DocumentRef ref); + /// Returns all matching [DocumentData] for given [ref]. Future> lookup(DocumentRef ref); @@ -56,6 +58,11 @@ final class DocumentsServiceImpl implements DocumentsService { return _documentRepository.removeAll(keepLocalDrafts: keepLocalDrafts); } + @override + Future isFavorite(DocumentRef ref) { + return _documentRepository.isFavorite(ref); + } + @override Future> lookup(DocumentRef ref) { return _documentRepository.getAllDocumentsData(ref: ref); 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 fe3d6377179f..3136bea2f548 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 @@ -50,9 +50,6 @@ abstract interface class ProposalService { required SignedDocumentRef categoryId, }); - /// Similar to [watchFavoritesProposalsIds] stops after first emit. - Future> getFavoritesProposalsIds(); - Future getLatestProposalVersion({required DocumentRef ref}); Future getProposal({ @@ -121,14 +118,6 @@ abstract interface class ProposalService { required SignedDocumentRef categoryId, }); - /// Fetches favorites proposals ids of the user - Stream> watchFavoritesProposalsIds(); - - /// Emits when proposal fav status changes. - Stream watchIsFavoritesProposal({ - required DocumentRef ref, - }); - /// Streams changes to [isMaxProposalsLimitReached]. Stream watchMaxProposalsLimitReached(); @@ -242,13 +231,6 @@ final class ProposalServiceImpl implements ProposalService { ); } - @override - Future> getFavoritesProposalsIds() { - return _documentRepository - .watchAllDocumentsFavoriteIds(type: DocumentType.proposalDocument) - .first; - } - @override Future getLatestProposalVersion({required DocumentRef ref}) async { final latest = await _documentRepository.getLatestOf(ref: ref); @@ -492,18 +474,6 @@ final class ProposalServiceImpl implements ProposalService { ); } - @override - Stream> watchFavoritesProposalsIds() { - return _documentRepository.watchAllDocumentsFavoriteIds( - type: DocumentType.proposalDocument, - ); - } - - @override - Stream watchIsFavoritesProposal({required DocumentRef ref}) { - return _documentRepository.watchIsDocumentFavorite(ref: ref.toLoose()); - } - @override Stream watchMaxProposalsLimitReached() { return watchUserProposalsCount().map((count) { From 520a432e3d47d9e695cd54cf407c189bba9ca329 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Tue, 18 Nov 2025 14:11:47 +0100 Subject: [PATCH 18/30] deleteWhere tests --- .../src/database/dao/documents_v2_dao.dart | 23 ++ .../lib/src/document/document_repository.dart | 6 +- .../database_documents_data_source.dart | 11 +- .../source/document_data_local_source.dart | 5 +- .../database/dao/documents_v2_dao_test.dart | 277 ++++++++++++++++++ .../documents_v2_local_metadata_dao_test.dart | 232 +++++++++++++++ 6 files changed, 543 insertions(+), 11 deletions(-) create mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_local_metadata_dao_test.dart diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart index bac519b03dc8..489f2e9d9cbc 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart @@ -12,6 +12,16 @@ abstract interface class DocumentsV2Dao { /// Returns the total number of documents in the table. Future count(); + /// Deletes documents that meet a specific condition and returns the number of + /// documents deleted. + /// + /// This method is intended to be implemented by a concrete class that defines + /// the deletion criteria. For example, it could delete all documents that are + /// older than a certain date. + Future deleteWhere({ + List? notInType, + }); + /// Checks if a document exists by its reference. /// /// If [ref] is exact (has version), checks for the specific version. @@ -86,6 +96,19 @@ class DriftDocumentsV2Dao extends DatabaseAccessor return documentsV2.count().getSingleOrNull().then((value) => value ?? 0); } + @override + Future deleteWhere({ + List? notInType, + }) { + final query = delete(documentsV2); + + if (notInType != null) { + query.where((tbl) => tbl.type.isNotInValues(notInType)); + } + + return query.go(); + } + @override Future exists(DocumentRef ref) { final query = selectOnly(documentsV2) diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart index 339de477fb43..5d675c9a5323 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart @@ -405,9 +405,9 @@ final class DocumentRepositoryImpl implements DocumentRepository { bool keepLocalDrafts = false, }) async { final deletedDrafts = keepLocalDrafts ? 0 : await _drafts.deleteAll(); - final deletedDocuments = keepLocalDrafts - ? await _localDocuments.deleteAllRespectingLocalDrafts() - : await _localDocuments.deleteAll(); + final deletedDocuments = await _localDocuments.deleteAll( + notInType: keepLocalDrafts ? [DocumentType.proposalTemplate] : null, + ); return deletedDrafts + deletedDocuments; } 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 49f7a91e63f4..ada249bf2a6b 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 @@ -24,13 +24,10 @@ final class DatabaseDocumentsDataSource ); @override - Future deleteAll() { - return _database.documentsDao.deleteAll(); - } - - @override - Future deleteAllRespectingLocalDrafts() { - return _database.documentsDao.deleteAll(keepTemplatesForLocalDrafts: true); + Future deleteAll({ + List? notInType, + }) { + return _database.documentsV2Dao.deleteWhere(notInType: notInType); } @override diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart index 9e45b5e0de0d..73c97359377d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart @@ -44,7 +44,10 @@ abstract interface class DraftDataSource implements DocumentDataLocalSource { /// See [DatabaseDocumentsDataSource]. abstract interface class SignedDocumentDataSource implements DocumentDataLocalSource { - Future deleteAllRespectingLocalDrafts(); + @override + Future deleteAll({ + List? notInType, + }); Future getRefCount({ required DocumentRef ref, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart index 4359951c06a6..23edfe47f1f2 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart @@ -1007,6 +1007,283 @@ void main() { expect(result!.version, versions[3]); }); }); + + group('deleteWhere', () { + test('returns zero when database is empty', () async { + // Given: An empty database + + // When + final result = await dao.deleteWhere(); + + // Then + expect(result, 0); + }); + + test('deletes all documents when no filter is provided', () async { + // Given + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: 'ver-1', + type: DocumentType.proposalDocument, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: 'ver-2', + type: DocumentType.commentDocument, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: 'ver-3', + type: DocumentType.proposalTemplate, + ), + ]; + await dao.saveAll(entities); + + // When + final result = await dao.deleteWhere(); + + // Then + expect(result, 3); + expect(await dao.count(), 0); + }); + + test('deletes documents not in notInType list', () async { + // Given + final proposal = _createTestDocumentEntity( + id: 'proposal-id', + ver: 'proposal-ver', + type: DocumentType.proposalDocument, + ); + final comment = _createTestDocumentEntity( + id: 'comment-id', + ver: 'comment-ver', + type: DocumentType.commentDocument, + ); + final template = _createTestDocumentEntity( + id: 'template-id', + ver: 'template-ver', + type: DocumentType.proposalTemplate, + ); + await dao.saveAll([proposal, comment, template]); + + // When + final result = await dao.deleteWhere( + notInType: [DocumentType.proposalDocument], + ); + + // Then + expect(result, 2); + expect(await dao.count(), 1); + + final remaining = await dao.getDocument( + const SignedDocumentRef.exact(id: 'proposal-id', version: 'proposal-ver'), + ); + expect(remaining, isNotNull); + expect(remaining!.type, DocumentType.proposalDocument); + }); + + test('keeps multiple document types when specified in notInType', () async { + // Given + final proposal = _createTestDocumentEntity( + id: 'proposal-id', + ver: 'proposal-ver', + type: DocumentType.proposalDocument, + ); + final comment = _createTestDocumentEntity( + id: 'comment-id', + ver: 'comment-ver', + type: DocumentType.commentDocument, + ); + final template = _createTestDocumentEntity( + id: 'template-id', + ver: 'template-ver', + type: DocumentType.proposalTemplate, + ); + final action = _createTestDocumentEntity( + id: 'action-id', + ver: 'action-ver', + type: DocumentType.proposalActionDocument, + ); + await dao.saveAll([proposal, comment, template, action]); + + // When + final result = await dao.deleteWhere( + notInType: [ + DocumentType.proposalDocument, + DocumentType.proposalTemplate, + ], + ); + + // Then + expect(result, 2); + expect(await dao.count(), 2); + + final remainingProposal = await dao.getDocument( + const SignedDocumentRef.exact(id: 'proposal-id', version: 'proposal-ver'), + ); + final remainingTemplate = await dao.getDocument( + const SignedDocumentRef.exact(id: 'template-id', version: 'template-ver'), + ); + final deletedComment = await dao.getDocument( + const SignedDocumentRef.exact(id: 'comment-id', version: 'comment-ver'), + ); + final deletedAction = await dao.getDocument( + const SignedDocumentRef.exact(id: 'action-id', version: 'action-ver'), + ); + + expect(remainingProposal, isNotNull); + expect(remainingTemplate, isNotNull); + expect(deletedComment, isNull); + expect(deletedAction, isNull); + }); + + test('deletes all documents when notInType is empty list', () async { + // Given + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: 'ver-1', + type: DocumentType.proposalDocument, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: 'ver-2', + type: DocumentType.commentDocument, + ), + ]; + await dao.saveAll(entities); + + // When + final result = await dao.deleteWhere(notInType: []); + + // Then + expect(result, 2); + expect(await dao.count(), 0); + }); + + test('returns zero when all documents match notInType filter', () async { + // Given + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: 'ver-1', + type: DocumentType.proposalDocument, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: 'ver-2', + type: DocumentType.proposalDocument, + ), + ]; + await dao.saveAll(entities); + + // When + final result = await dao.deleteWhere( + notInType: [DocumentType.proposalDocument], + ); + + // Then + expect(result, 0); + expect(await dao.count(), 2); + }); + + test('handles multiple versions of same document id', () async { + // Given + final v1 = _createTestDocumentEntity( + id: 'multi-id', + ver: 'ver-1', + type: DocumentType.proposalDocument, + ); + final v2 = _createTestDocumentEntity( + id: 'multi-id', + ver: 'ver-2', + type: DocumentType.proposalDocument, + ); + final other = _createTestDocumentEntity( + id: 'other-id', + ver: 'other-ver', + type: DocumentType.commentDocument, + ); + await dao.saveAll([v1, v2, other]); + + // When + final result = await dao.deleteWhere( + notInType: [DocumentType.proposalDocument], + ); + + // Then + expect(result, 1); + expect(await dao.count(), 2); + }); + + test('deletes documents with all different types correctly', () async { + // Given + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: 'ver-1', + type: DocumentType.proposalDocument, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: 'ver-2', + type: DocumentType.commentDocument, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: 'ver-3', + type: DocumentType.reviewDocument, + ), + _createTestDocumentEntity( + id: 'id-4', + ver: 'ver-4', + type: DocumentType.proposalActionDocument, + ), + _createTestDocumentEntity( + id: 'id-5', + ver: 'ver-5', + type: DocumentType.proposalTemplate, + ), + ]; + await dao.saveAll(entities); + + // When + final result = await dao.deleteWhere( + notInType: [ + DocumentType.proposalDocument, + DocumentType.proposalTemplate, + DocumentType.proposalActionDocument, + ], + ); + + // Then + expect(result, 2); + expect(await dao.count(), 3); + }); + + test('performs efficiently with large dataset', () async { + // Given + final entities = List.generate( + 1000, + (i) => _createTestDocumentEntity( + id: 'id-$i', + ver: 'ver-$i', + type: i.isEven ? DocumentType.proposalDocument : DocumentType.commentDocument, + ), + ); + await dao.saveAll(entities); + + // When + final result = await dao.deleteWhere( + notInType: [DocumentType.proposalDocument], + ); + + // Then + expect(result, 500); + expect(await dao.count(), 500); + }); + }); }); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_local_metadata_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_local_metadata_dao_test.dart new file mode 100644 index 000000000000..9851af2b95c5 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_local_metadata_dao_test.dart @@ -0,0 +1,232 @@ +import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; +import 'package:catalyst_voices_repositories/src/database/dao/documents_v2_local_metadata_dao.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_local_metadata.drift.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../connection/test_connection.dart'; + +void main() { + late DriftCatalystDatabase db; + late DocumentsV2LocalMetadataDao dao; + + setUp(() async { + final connection = await buildTestConnection(); + db = DriftCatalystDatabase(connection); + dao = db.driftDocumentsV2LocalMetadataDao; + }); + + tearDown(() async { + await db.close(); + }); + + group(DriftDocumentsV2LocalMetadataDao, () { + group('deleteWhere', () { + test('returns zero when database is empty', () async { + // Given: An empty database + + // When + final result = await dao.deleteWhere(); + + // Then + expect(result, 0); + }); + + test('deletes single record and returns count', () async { + // Given + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'doc-1', + isFavorite: true, + ), + ); + + // When + final result = await dao.deleteWhere(); + + // Then + expect(result, 1); + }); + + test('deletes all records and returns total count', () async { + // Given + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'doc-1', + isFavorite: true, + ), + ); + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'doc-2', + isFavorite: false, + ), + ); + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'doc-3', + isFavorite: true, + ), + ); + + // When + final result = await dao.deleteWhere(); + + // Then + expect(result, 3); + }); + + test('table is empty after deletion', () async { + // Given + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'doc-1', + isFavorite: true, + ), + ); + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'doc-2', + isFavorite: true, + ), + ); + + // When + await dao.deleteWhere(); + + // Then + final remaining = await db.select(db.documentsLocalMetadata).get(); + expect(remaining, isEmpty); + }); + + test('subsequent delete returns zero', () async { + // Given + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'doc-1', + isFavorite: true, + ), + ); + await dao.deleteWhere(); + + // When + final result = await dao.deleteWhere(); + + // Then + expect(result, 0); + }); + }); + + group('isFavorite', () { + test('returns false when document does not exist', () async { + // Given: An empty database + + // When + final result = await dao.isFavorite('non-existent-id'); + + // Then + expect(result, false); + }); + + test('returns false when document exists but is not favorite', () async { + // Given + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'doc-1', + isFavorite: false, + ), + ); + + // When + final result = await dao.isFavorite('doc-1'); + + // Then + expect(result, false); + }); + + test('returns true when document is marked as favorite', () async { + // Given + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'doc-1', + isFavorite: true, + ), + ); + + // When + final result = await dao.isFavorite('doc-1'); + + // Then + expect(result, true); + }); + + test('returns correct value for specific document among multiple', () async { + // Given + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'doc-1', + isFavorite: true, + ), + ); + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'doc-2', + isFavorite: false, + ), + ); + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'doc-3', + isFavorite: true, + ), + ); + + // When & Then + expect(await dao.isFavorite('doc-1'), true); + expect(await dao.isFavorite('doc-2'), false); + expect(await dao.isFavorite('doc-3'), true); + }); + + test('returns false for non-existent id among existing records', () async { + // Given + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'doc-1', + isFavorite: true, + ), + ); + + // When + final result = await dao.isFavorite('doc-2'); + + // Then + expect(result, false); + }); + }); + }); +} From 16090d582796bd132508b8c82ee88fed67fcda11 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Tue, 18 Nov 2025 15:18:00 +0100 Subject: [PATCH 19/30] remove old pagination and count methods --- .../proposal_builder_bloc.dart | 14 +- .../lib/src/catalyst_voices_models.dart | 3 - .../lib/src/proposals/proposals_count.dart | 85 ---------- .../proposals/proposals_count_filters.dart | 30 ---- .../lib/src/proposals/proposals_filters.dart | 101 ------------ .../src/database/dao/documents_v2_dao.dart | 113 +++++++++++-- .../lib/src/document/document_repository.dart | 8 +- .../database_documents_data_source.dart | 83 +--------- .../source/database_drafts_data_source.dart | 11 +- .../source/document_data_local_source.dart | 2 +- .../proposal_document_data_local_source.dart | 25 --- .../lib/src/proposal/proposal_repository.dart | 87 ---------- .../database/dao/documents_v2_dao_test.dart | 20 +-- .../lib/src/documents/documents_service.dart | 2 +- .../lib/src/proposal/proposal_service.dart | 154 ++---------------- .../src/proposal/proposal_service_test.dart | 7 +- 16 files changed, 150 insertions(+), 595 deletions(-) delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_count.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_count_filters.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_filters.dart diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/proposal_builder_bloc.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/proposal_builder_bloc.dart index 97cac1907e30..67409a066ea7 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/proposal_builder_bloc.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/proposal_builder_bloc.dart @@ -944,12 +944,22 @@ final class ProposalBuilderBloc extends Bloc get props => [ - total, - drafts, - finals, - favorites, - favoritesFinals, - my, - myFinals, - voted, - ]; - - ProposalsCount copyWith({ - int? total, - int? drafts, - int? finals, - int? favorites, - int? favoritesFinals, - int? my, - int? myFinals, - int? voted, - }) { - return ProposalsCount( - total: total ?? this.total, - drafts: drafts ?? this.drafts, - finals: finals ?? this.finals, - favorites: favorites ?? this.favorites, - favoritesFinals: favoritesFinals ?? this.favoritesFinals, - my: my ?? this.my, - myFinals: myFinals ?? this.myFinals, - voted: voted ?? this.voted, - ); - } - - int ofType(ProposalsFilterType type) { - return switch (type) { - ProposalsFilterType.total => total, - ProposalsFilterType.drafts => drafts, - ProposalsFilterType.finals => finals, - ProposalsFilterType.favorites => favorites, - ProposalsFilterType.favoritesFinals => favoritesFinals, - ProposalsFilterType.my => my, - ProposalsFilterType.myFinals => myFinals, - ProposalsFilterType.voted => voted, - }; - } - - @override - String toString() { - return 'ProposalsCount(' - 'total[$total], ' - 'drafts[$drafts], ' - 'finals[$finals], ' - 'favorites[$favorites], ' - 'favoritesFinals[$favoritesFinals], ' - 'my[$my], ' - 'myFinals[$myFinals], ' - 'voted[$voted]' - ')'; - } -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_count_filters.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_count_filters.dart deleted file mode 100644 index 5e7bc2827696..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_count_filters.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:equatable/equatable.dart'; - -final class ProposalsCountFilters extends Equatable { - final CatalystId? author; - final bool? onlyAuthor; - final SignedDocumentRef? category; - final String? searchQuery; - final Duration? maxAge; - final CampaignFilters? campaign; - - const ProposalsCountFilters({ - this.author, - this.onlyAuthor, - this.category, - this.searchQuery, - this.maxAge, - this.campaign, - }); - - @override - List get props => [ - author, - onlyAuthor, - category, - searchQuery, - maxAge, - campaign, - ]; -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_filters.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_filters.dart deleted file mode 100644 index 9eded976ca98..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_filters.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:equatable/equatable.dart'; - -final class ProposalsFilters extends Equatable { - final ProposalsFilterType type; - final CatalystId? author; - final bool? onlyAuthor; - final SignedDocumentRef? category; - final String? searchQuery; - final Duration? maxAge; - final CampaignFilters? campaign; - - const ProposalsFilters({ - this.type = ProposalsFilterType.total, - this.author, - this.onlyAuthor, - this.category, - this.searchQuery, - this.maxAge, - this.campaign, - }); - - ProposalsFilters.forActiveCampaign({ - this.type = ProposalsFilterType.total, - this.author, - this.onlyAuthor, - this.category, - this.searchQuery, - this.maxAge, - }) : campaign = CampaignFilters.active(); - - @override - List get props => [ - type, - author, - onlyAuthor, - category, - searchQuery, - maxAge, - campaign, - ]; - - ProposalsFilters copyWith({ - ProposalsFilterType? type, - Optional? author, - Optional? onlyAuthor, - Optional? category, - Optional? searchQuery, - Optional? maxAge, - Optional? campaign, - }) { - return ProposalsFilters( - type: type ?? this.type, - author: author.dataOr(this.author), - onlyAuthor: onlyAuthor.dataOr(this.onlyAuthor), - category: category.dataOr(this.category), - searchQuery: searchQuery.dataOr(this.searchQuery), - maxAge: maxAge.dataOr(this.maxAge), - campaign: campaign.dataOr(this.campaign), - ); - } - - ProposalsCountFilters toCountFilters() { - return ProposalsCountFilters( - author: author, - onlyAuthor: onlyAuthor, - category: category, - searchQuery: searchQuery, - maxAge: maxAge, - campaign: campaign, - ); - } - - @override - String toString() => - 'ProposalsFilters(' - 'type[${type.name}], ' - 'author[$author], ' - 'onlyAuthor[$onlyAuthor], ' - 'category[$category], ' - 'searchQuery[$searchQuery], ' - 'maxAge[$maxAge], ' - 'campaign[$campaign]' - ')'; -} - -enum ProposalsFilterType { - total, - drafts, - finals, - favorites, - favoritesFinals, - my, - myFinals, - voted; - - bool get isFavorite => - this == ProposalsFilterType.favorites || this == ProposalsFilterType.favoritesFinals; - - bool get isMy => this == ProposalsFilterType.my || this == ProposalsFilterType.myFinals; -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart index 489f2e9d9cbc..1ee9d41d65f0 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart @@ -38,12 +38,34 @@ abstract interface class DocumentsV2Dao { /// Suitable for synchronizing many documents with minimal database round-trips. Future> filterExisting(List refs); - /// Retrieves a document by its reference. + /// Retrieves a document. /// - /// If [ref] is exact (has version), returns the specific version. - /// If loose (no version), returns the latest version by createdAt. + /// If [ref] is provided and is exact (has version), returns the specific version. + /// If [ref] is loose (no version), returns the latest version by createdAt. + /// If [author] is non null tries to find matching document /// Returns null if no matching document is found. - Future getDocument(DocumentRef ref); + Future getDocument({ + DocumentRef? ref, + CatalystId? author, + }); + + /// Retrieves a list of documents that match the given criteria. + /// + /// This method returns a list of documents that match the given criteria. + /// - [id]: optional filter to only include documents with matching id. + /// - [type]: Optional filter to only include documents of a specific [DocumentType]. + /// - [filters]: Optional campaign filter. + /// - [latestOnly] is true only newest version per id is returned. + /// - [limit]: The maximum number of documents to return. + /// - [offset]: The number of documents to skip for pagination. + Future> getDocuments({ + String? id, + DocumentType? type, + CampaignFilters? filters, + bool latestOnly, + int limit, + int offset, + }); /// Finds the latest version of a document. /// @@ -66,12 +88,14 @@ abstract interface class DocumentsV2Dao { /// /// This method returns a stream that emits a new list of documents whenever /// the underlying data changes. + /// - [id]: optional filter to only include documents with matching id. /// - [type]: Optional filter to only include documents of a specific [DocumentType]. /// - [filters]: Optional campaign filter. /// - [latestOnly] is true only newest version per id is returned. /// - [limit]: The maximum number of documents to return. /// - [offset]: The number of documents to skip for pagination. Stream> watchDocuments({ + String? id, DocumentType? type, CampaignFilters? filters, bool latestOnly, @@ -161,20 +185,60 @@ class DriftDocumentsV2Dao extends DatabaseAccessor } @override - Future getDocument(DocumentRef ref) { - final query = select(documentsV2)..where((tbl) => tbl.id.equals(ref.id)); + Future getDocument({ + DocumentRef? ref, + CatalystId? author, + }) { + final query = select(documentsV2); - if (ref.isExact) { - query.where((tbl) => tbl.ver.equals(ref.version!)); - } else { - query - ..orderBy([(tbl) => OrderingTerm.desc(tbl.createdAt)]) - ..limit(1); + if (ref != null) { + query.where((tbl) => tbl.id.equals(ref.id)); + + if (ref.isExact) { + query.where((tbl) => tbl.ver.equals(ref.version!)); + } } + // TODO(damian-molinski): test it + if (author != null) { + final significant = author.toSignificant().toUri().toString(); + query.where((tbl) { + final authorDocs = subqueryExpression( + selectOnly(documentAuthors) + ..addColumns([documentAuthors.documentId]) + ..where(documentAuthors.authorIdSignificant.equals(significant)) + ..where(documentAuthors.documentId.equalsExp(documentsV2.id)), + ); + return tbl.id.equalsExp(authorDocs); + }); + } + + query + ..orderBy([(tbl) => OrderingTerm.desc(tbl.createdAt)]) + ..limit(1); + return query.getSingleOrNull(); } + @override + Future> getDocuments({ + String? id, + DocumentType? type, + CampaignFilters? filters, + bool latestOnly = false, + int limit = 200, + int offset = 0, + }) { + return _queryDocuments( + id: id, + type: type, + filters: filters, + latestOnly: latestOnly, + limit: limit, + offset: offset, + ).get(); + } + @override Future getLatestOf(DocumentRef ref) { final query = selectOnly(documentsV2) @@ -222,16 +286,39 @@ class DriftDocumentsV2Dao extends DatabaseAccessor @override Stream> watchDocuments({ + String? id, DocumentType? type, CampaignFilters? filters, bool latestOnly = false, int limit = 200, int offset = 0, + }) { + return _queryDocuments( + id: id, + type: type, + filters: filters, + latestOnly: latestOnly, + limit: limit, + offset: offset, + ).watch(); + } + + SimpleSelectStatement<$DocumentsV2Table, DocumentEntityV2> _queryDocuments({ + String? id, + DocumentType? type, + CampaignFilters? filters, + required bool latestOnly, + required int limit, + required int offset, }) { final effectiveLimit = limit.clamp(0, 999); final query = select(documentsV2); + if (id != null) { + query.where((tbl) => tbl.id.equals(id)); + } + if (filters != null) { query.where((tbl) => tbl.categoryId.isIn(filters.categoriesIds)); } @@ -255,6 +342,6 @@ class DriftDocumentsV2Dao extends DatabaseAccessor query.limit(effectiveLimit, offset: offset); - return query.watch(); + return query; } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart index 5d675c9a5323..c2f83f16d68a 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart @@ -49,7 +49,7 @@ abstract interface class DocumentRepository { }); /// Returns all matching [DocumentData] to given [ref]. - Future> getAllDocumentsData({ + Future> findAllVersions({ required DocumentRef ref, }); @@ -254,10 +254,10 @@ final class DocumentRepositoryImpl implements DocumentRepository { } @override - Future> getAllDocumentsData({required DocumentRef ref}) async { + Future> findAllVersions({required DocumentRef ref}) async { final all = switch (ref) { - DraftRef() => await _drafts.getAll(ref: ref), - SignedDocumentRef() => await _localDocuments.getAll(ref: ref), + DraftRef() => await _drafts.findAllVersions(ref: ref), + SignedDocumentRef() => await _localDocuments.findAllVersions(ref: ref), }..sort(); return all; 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 ada249bf2a6b..bceb228a2818 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 @@ -40,9 +40,16 @@ final class DatabaseDocumentsDataSource return _database.documentsV2Dao.filterExisting(refs); } + @override + Future> findAllVersions({required DocumentRef ref}) { + return _database.documentsV2Dao + .getDocuments(id: ref.id) + .then((value) => value.map((e) => e.toModel()).toList()); + } + @override Future get({required DocumentRef ref}) async { - final entity = await _database.documentsV2Dao.getDocument(ref); + final entity = await _database.documentsV2Dao.getDocument(ref: ref); if (entity == null) { throw DocumentNotFoundException(ref: ref); } @@ -50,20 +57,11 @@ final class DatabaseDocumentsDataSource return entity.toModel(); } - @override - Future> getAll({required DocumentRef ref}) { - return _database.documentsDao - .queryAll(ref: ref) - .then((value) => value.map((e) => e.toModel()).toList()); - } - @override Future getLatest({ CatalystId? authorId, }) { - return _database.documentsDao - .queryLatestDocumentData(authorId: authorId) - .then((value) => value?.toModel()); + return _database.documentsV2Dao.getDocument(author: authorId).then((value) => value?.toModel()); } @override @@ -71,30 +69,6 @@ final class DatabaseDocumentsDataSource return _database.documentsV2Dao.getLatestOf(ref); } - @override - Future> getProposals({ - SignedDocumentRef? categoryRef, - required ProposalsFilterType type, - }) { - return _database.proposalsDao - .queryProposals( - categoryRef: categoryRef, - filters: ProposalsFilters.forActiveCampaign(type: type), - ) - .then((value) => value.map((e) => e.toModel()).toList()); - } - - @override - Future> getProposalsPage({ - required PageRequest request, - required ProposalsFilters filters, - required ProposalsOrder order, - }) { - return _database.proposalsDao - .queryProposalsPage(request: request, filters: filters, order: order) - .then((page) => page.map((e) => e.toModel())); - } - @override Future getProposalsTotalTask({ required NodeId nodeId, @@ -202,13 +176,6 @@ final class DatabaseDocumentsDataSource .map((page) => page.map((data) => data.toModel())); } - @override - Stream watchProposalsCount({ - required ProposalsCountFilters filters, - }) { - return _database.proposalsDao.watchCount(filters: filters); - } - @override Stream watchProposalsCountV2({ ProposalsFiltersV2 filters = const ProposalsFiltersV2(), @@ -222,17 +189,6 @@ final class DatabaseDocumentsDataSource ); } - @override - Stream> watchProposalsPage({ - required PageRequest request, - required ProposalsFilters filters, - required ProposalsOrder order, - }) { - return _database.proposalsDao - .watchProposalsPage(request: request, filters: filters, order: order) - .map((page) => page.map((e) => e.toModel())); - } - @override Stream watchProposalsTotalTask({ required NodeId nodeId, @@ -264,15 +220,6 @@ final class DatabaseDocumentsDataSource } } -extension on DocumentEntity { - DocumentData toModel() { - return DocumentData( - metadata: metadata, - content: content, - ); - } -} - extension on DocumentEntityV2 { DocumentData toModel() { return DocumentData( @@ -337,18 +284,6 @@ extension on DocumentData { } } -extension on JoinedProposalEntity { - ProposalDocumentData toModel() { - return ProposalDocumentData( - proposal: proposal.toModel(), - template: template.toModel(), - action: action?.toModel(), - commentsCount: commentsCount, - versions: versions, - ); - } -} - extension on JoinedProposalBriefEntity { JoinedProposalBriefData toModel() { final proposalDocumentData = proposal.toModel(); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart index a18412ea1547..a8247febb81a 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart @@ -39,7 +39,7 @@ final class DatabaseDraftsDataSource implements DraftDataSource { } @override - Future> getAll({required DocumentRef ref}) { + Future> findAllVersions({required DocumentRef ref}) { return _database.draftsDao .queryAll(ref: ref) .then((value) => value.map((e) => e.toModel()).toList()); @@ -105,15 +105,6 @@ final class DatabaseDraftsDataSource implements DraftDataSource { } } -extension on DocumentDraftEntity { - DocumentData toModel() { - return DocumentData( - metadata: metadata, - content: content, - ); - } -} - extension on DocumentData { /*LocalDocumentDraftEntity toEntity() { return LocalDocumentDraftEntity( diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart index 73c97359377d..f15aecc55cda 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart @@ -9,7 +9,7 @@ abstract interface class DocumentDataLocalSource implements DocumentDataSource { Future> filterExisting(List refs); - Future> getAll({required DocumentRef ref}); + Future> findAllVersions({required DocumentRef ref}); Future getLatest({ CatalystId? authorId, 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 2ef41dfff69c..10742cd962ab 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 @@ -5,21 +5,6 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; /// implement those queries in abstract way without know all logic related /// just to proposals. abstract interface class ProposalDocumentDataLocalSource { - /// Used to retrieve all proposals. Offers way to filter proposals by passing - /// category ref and proposal filter type. - /// - /// If [categoryRef] is null then all proposals are returned. - Future> getProposals({ - SignedDocumentRef? categoryRef, - required ProposalsFilterType type, - }); - - Future> getProposalsPage({ - required PageRequest request, - required ProposalsFilters filters, - required ProposalsOrder order, - }); - Future getProposalsTotalTask({ required NodeId nodeId, required ProposalsTotalAskFilters filters, @@ -36,20 +21,10 @@ abstract interface class ProposalDocumentDataLocalSource { ProposalsFiltersV2 filters, }); - Stream watchProposalsCount({ - required ProposalsCountFilters filters, - }); - Stream watchProposalsCountV2({ ProposalsFiltersV2 filters, }); - Stream> watchProposalsPage({ - required PageRequest request, - required ProposalsFilters filters, - required ProposalsOrder order, - }); - Stream watchProposalsTotalTask({ required NodeId nodeId, required ProposalsTotalAskFilters 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 bfc50c44b47b..c4c5b3fe56dc 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 @@ -31,19 +31,6 @@ abstract interface class ProposalRepository { required DocumentRef ref, }); - Future> getProposals({ - SignedDocumentRef? categoryRef, - required ProposalsFilterType type, - }); - - /// Fetches all proposals for page matching [request] as well as - /// [filters]. - Future> getProposalsPage({ - required PageRequest request, - required ProposalsFilters filters, - required ProposalsOrder order, - }); - /// Returns [ProposalTemplate] for matching [ref]. /// /// Source of data depends whether [ref] is [SignedDocumentRef] or [DraftRef]. @@ -99,20 +86,10 @@ abstract interface class ProposalRepository { ProposalsFiltersV2 filters, }); - Stream watchProposalsCount({ - required ProposalsCountFilters filters, - }); - Stream watchProposalsCountV2({ ProposalsFiltersV2 filters, }); - Stream> watchProposalsPage({ - required PageRequest request, - required ProposalsFilters filters, - required ProposalsOrder order, - }); - Stream> watchProposalTemplates({ required CampaignFilters filters, }); @@ -187,27 +164,6 @@ final class ProposalRepositoryImpl implements ProposalRepository { return _getProposalPublish(ref: ref, action: action); } - @override - Future> getProposals({ - SignedDocumentRef? categoryRef, - required ProposalsFilterType type, - }) async { - return _proposalsLocalSource - .getProposals(type: type, categoryRef: categoryRef) - .then((value) => value.map(_buildProposalData).toList()); - } - - @override - Future> getProposalsPage({ - required PageRequest request, - required ProposalsFilters filters, - required ProposalsOrder order, - }) { - return _proposalsLocalSource - .getProposalsPage(request: request, filters: filters, order: order) - .then((value) => value.map(_buildProposalData)); - } - @override Future getProposalTemplate({ required DocumentRef ref, @@ -357,13 +313,6 @@ final class ProposalRepositoryImpl implements ProposalRepository { ); } - @override - Stream watchProposalsCount({ - required ProposalsCountFilters filters, - }) { - return _proposalsLocalSource.watchProposalsCount(filters: filters); - } - @override Stream watchProposalsCountV2({ ProposalsFiltersV2 filters = const ProposalsFiltersV2(), @@ -371,17 +320,6 @@ final class ProposalRepositoryImpl implements ProposalRepository { return _proposalsLocalSource.watchProposalsCountV2(filters: filters); } - @override - Stream> watchProposalsPage({ - required PageRequest request, - required ProposalsFilters filters, - required ProposalsOrder order, - }) { - return _proposalsLocalSource - .watchProposalsPage(request: request, filters: filters, order: order) - .map((value) => value.map(_buildProposalData)); - } - @override Stream> watchProposalTemplates({ required CampaignFilters filters, @@ -428,31 +366,6 @@ final class ProposalRepositoryImpl implements ProposalRepository { return dto.action.toModel(); } - ProposalData _buildProposalData(ProposalDocumentData data) { - final action = _buildProposalActionData(data.action); - - final publish = switch (action) { - ProposalSubmissionAction.aFinal => ProposalPublish.submittedProposal, - ProposalSubmissionAction.draft || null => ProposalPublish.publishedDraft, - ProposalSubmissionAction.hide => throw ArgumentError( - 'Proposal(${data.proposal.metadata.selfRef}) is ' - 'unsupported ${ProposalSubmissionAction.hide}. Make sure to filter ' - 'out hidden proposals before this code is reached.', - ), - }; - - final document = _buildProposalDocument( - documentData: data.proposal, - templateData: data.template, - ); - - return ProposalData( - document: document, - publish: publish, - commentsCount: data.commentsCount, - ); - } - ProposalDocument _buildProposalDocument({ required DocumentData documentData, required DocumentData templateData, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart index 23edfe47f1f2..5ff3b5876707 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart @@ -295,7 +295,7 @@ void main() { const ref = SignedDocumentRef.exact(id: 'non-existent-id', version: 'non-existent-ver'); // When - final result = await dao.getDocument(ref); + final result = await dao.getDocument(ref: ref); // Then expect(result, isNull); @@ -310,7 +310,7 @@ void main() { const ref = SignedDocumentRef.exact(id: 'test-id', version: 'test-ver'); // When - final result = await dao.getDocument(ref); + final result = await dao.getDocument(ref: ref); // Then expect(result, isNotNull); @@ -327,7 +327,7 @@ void main() { const ref = SignedDocumentRef.exact(id: 'test-id', version: 'wrong-ver'); // When: getDocument is called - final result = await dao.getDocument(ref); + final result = await dao.getDocument(ref: ref); // Then: Returns null expect(result, isNull); @@ -348,7 +348,7 @@ void main() { const ref = SignedDocumentRef.loose(id: 'test-id'); // When - final result = await dao.getDocument(ref); + final result = await dao.getDocument(ref: ref); // Then expect(result, isNotNull); @@ -365,7 +365,7 @@ void main() { const ref = SignedDocumentRef.loose(id: 'non-existent-id'); // When - final result = await dao.getDocument(ref); + final result = await dao.getDocument(ref: ref); // Then expect(result, isNull); @@ -1077,7 +1077,7 @@ void main() { expect(await dao.count(), 1); final remaining = await dao.getDocument( - const SignedDocumentRef.exact(id: 'proposal-id', version: 'proposal-ver'), + ref: const SignedDocumentRef.exact(id: 'proposal-id', version: 'proposal-ver'), ); expect(remaining, isNotNull); expect(remaining!.type, DocumentType.proposalDocument); @@ -1120,16 +1120,16 @@ void main() { expect(await dao.count(), 2); final remainingProposal = await dao.getDocument( - const SignedDocumentRef.exact(id: 'proposal-id', version: 'proposal-ver'), + ref: const SignedDocumentRef.exact(id: 'proposal-id', version: 'proposal-ver'), ); final remainingTemplate = await dao.getDocument( - const SignedDocumentRef.exact(id: 'template-id', version: 'template-ver'), + ref: const SignedDocumentRef.exact(id: 'template-id', version: 'template-ver'), ); final deletedComment = await dao.getDocument( - const SignedDocumentRef.exact(id: 'comment-id', version: 'comment-ver'), + ref: const SignedDocumentRef.exact(id: 'comment-id', version: 'comment-ver'), ); final deletedAction = await dao.getDocument( - const SignedDocumentRef.exact(id: 'action-id', version: 'action-ver'), + ref: const SignedDocumentRef.exact(id: 'action-id', version: 'action-ver'), ); expect(remainingProposal, isNotNull); diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/documents/documents_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/documents/documents_service.dart index 157d04fb0f36..68aea347a710 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/documents/documents_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/documents/documents_service.dart @@ -65,7 +65,7 @@ final class DocumentsServiceImpl implements DocumentsService { @override Future> lookup(DocumentRef ref) { - return _documentRepository.getAllDocumentsData(ref: ref); + return _documentRepository.findAllVersions(ref: ref); } @override 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 3136bea2f548..c2084e569219 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 @@ -60,12 +60,6 @@ abstract interface class ProposalService { required DocumentRef ref, }); - Future> getProposalsPage({ - required PageRequest request, - required ProposalsFilters filters, - required ProposalsOrder order, - }); - Future getProposalTemplate({ required DocumentRef ref, }); @@ -127,23 +121,11 @@ abstract interface class ProposalService { ProposalsFiltersV2 filters, }); - Stream watchProposalsCount({ - required ProposalsCountFilters filters, - }); - Stream watchProposalsCountV2({ ProposalsFiltersV2 filters, }); - Stream> watchProposalsPage({ - required PageRequest request, - required ProposalsFilters filters, - required ProposalsOrder order, - }); - Stream> watchUserProposals(); - - Stream watchUserProposalsCount(); } final class ProposalServiceImpl implements ProposalService { @@ -264,61 +246,6 @@ final class ProposalServiceImpl implements ProposalService { ); } - @override - Future> getProposalsPage({ - required PageRequest request, - required ProposalsFilters filters, - required ProposalsOrder order, - }) async { - // TODO(LynxxLynx): Only for mocking! Remove this when we start supporting writing votes to the database - final originalFilters = filters; - if (filters.type == ProposalsFilterType.voted) { - filters = filters.copyWith(type: ProposalsFilterType.total); - } - - final proposalsPage = await _proposalRepository - .getProposalsPage(request: request, filters: filters, order: order) - .then(_mapProposalDataPage); - var proposals = proposalsPage.items; - - // TODO(LynxxLynx): Only for mocking! Remove this when we start supporting writing votes to the database - if (originalFilters.type == ProposalsFilterType.voted) { - final votedProposals = _castedVotesObserver.votes; - final votedProposalIds = votedProposals.map((vote) => vote.proposal).toSet(); - proposals = proposals - .where((proposal) => votedProposalIds.contains(proposal.selfRef)) - .toList(); - } - - final categoriesRefs = proposals.map((proposal) => proposal.categoryRef).toSet(); - - // If we are getting proposals then campaign needs to be active - // Getting whole campaign with list of categories saves time then calling to get each category separately - // for each proposal - final activeCampaign = _activeCampaignObserver.campaign; - - final categories = Map.fromEntries( - categoriesRefs.map((ref) { - final category = activeCampaign!.categories.firstWhere( - (category) => category.selfRef == ref, - ); - return MapEntry(ref.id, category); - }), - ); - - final proposalsWithContext = proposals - .map( - (proposal) => ProposalWithContext( - proposal: proposal, - category: categories[proposal.categoryRef.id]!, - user: const ProposalUserContext(), - ), - ) - .toList(); - - return proposalsPage.copyWithItems(proposalsWithContext); - } - @override Future getProposalTemplate({ required DocumentRef ref, @@ -476,9 +403,16 @@ final class ProposalServiceImpl implements ProposalService { @override Stream watchMaxProposalsLimitReached() { - return watchUserProposalsCount().map((count) { - return count.finals >= ProposalDocument.maxSubmittedProposalsPerUser; - }); + // TODO(damian-molinski): watch active account id + active campain + const filters = const ProposalsFiltersV2( + status: ProposalStatusFilter.aFinal, + author: null, + campaign: null, + ); + + return _proposalRepository + .watchProposalsCountV2(filters: filters) + .map((event) => event >= ProposalDocument.maxSubmittedProposalsPerUser); } @override @@ -512,22 +446,6 @@ final class ProposalServiceImpl implements ProposalService { ); } - @override - Stream watchProposalsCount({ - required ProposalsCountFilters filters, - }) { - final proposalsCount = _proposalRepository.watchProposalsCount(filters: filters); - - // TODO(LynxxLynx): Remove this when we start supporting writing votes to the database - return proposalsCount.switchMap((count) { - return _castedVotesObserver.watchCastedVotes.map((votedProposals) { - return count.copyWith( - voted: votedProposals.length, - ); - }); - }); - } - @override Stream watchProposalsCountV2({ ProposalsFiltersV2 filters = const ProposalsFiltersV2(), @@ -539,17 +457,6 @@ final class ProposalServiceImpl implements ProposalService { ); } - @override - Stream> watchProposalsPage({ - required PageRequest request, - required ProposalsFilters filters, - required ProposalsOrder order, - }) { - return _proposalRepository - .watchProposalsPage(request: request, filters: filters, order: order) - .asyncMap(_mapProposalDataPage); - } - @override Stream> watchUserProposals() async* { yield* _userService.watchUser.distinct().switchMap((user) { @@ -608,28 +515,6 @@ final class ProposalServiceImpl implements ProposalService { }); } - @override - Stream watchUserProposalsCount() { - return _userService.watchUser.distinct().switchMap((user) { - final authorId = user.activeAccount?.catalystId; - if (!_isProposer(user) || authorId == null) { - // user is not eligible for creating proposals - return const Stream.empty(); - } - - final activeCampaign = _activeCampaignObserver.campaign; - final categoriesIds = activeCampaign?.categories.map((e) => e.selfRef.id).toList(); - - final filters = ProposalsCountFilters( - author: authorId, - onlyAuthor: true, - campaign: categoriesIds != null ? CampaignFilters(categoriesIds: categoriesIds) : null, - ); - - return watchProposalsCount(filters: filters); - }); - } - // TODO(damian-molinski): Remove this when voteBy is implemented. Stream _adaptFilters(ProposalsFiltersV2 filters) { if (filters.voteBy == null) { @@ -739,23 +624,4 @@ final class ProposalServiceImpl implements ProposalService { votes: isFinal ? ProposalBriefDataVotes(draft: draftVote, casted: castedVote) : null, ); } - - Future> _mapProposalDataPage(Page page) async { - final proposals = await page.items.map( - (item) async { - final versions = await _proposalRepository - .queryVersionsOfId( - id: item.document.metadata.selfRef.id, - includeLocalDrafts: true, - ) - .then( - (value) => value.map((e) => e.metadata.selfRef.version!).whereType().toList(), - ); - - return Proposal.fromData(item, versions); - }, - ).wait; - - return page.copyWithItems(proposals); - } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/proposal/proposal_service_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/proposal/proposal_service_test.dart index 64aac0907c43..6ae4e0d320a0 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/proposal/proposal_service_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/proposal/proposal_service_test.dart @@ -39,7 +39,6 @@ void main() { ); registerFallbackValue(const SignedDocumentRef(id: 'fallback-id')); - registerFallbackValue(const ProposalsCountFilters()); when( () => mockDocumentRepository.watchCount( @@ -61,14 +60,12 @@ void main() { isActive: true, ); final user = User.optional(accounts: [account]); - const proposalsCount = ProposalsCount( - finals: ProposalDocument.maxSubmittedProposalsPerUser + 1, - ); + const proposalsCount = ProposalDocument.maxSubmittedProposalsPerUser + 1; when(() => mockUserService.watchUser).thenAnswer((_) => Stream.value(user)); when( - () => mockProposalRepository.watchProposalsCount( + () => mockProposalRepository.watchProposalsCountV2( filters: any(named: 'filters'), ), ).thenAnswer((_) => Stream.value(proposalsCount)); From 5578eb0941271b10e7f357994c1592902c3082c1 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Tue, 18 Nov 2025 21:38:25 +0100 Subject: [PATCH 20/30] local draft documents dao --- .../apps/voices/lib/configs/bootstrap.dart | 2 +- .../lib/src/document/document_ref.dart | 2 + .../lib/src/catalyst_voices_repositories.dart | 2 + .../lib/src/database/catalyst_database.dart | 14 +- .../src/database/dao/documents_v2_dao.dart | 317 +++-- .../dao/local_draft_documents_v2_dao.dart | 394 ++++++ .../lib/src/database/dao/workspace_dao.dart | 32 - .../lib/src/database/database.dart | 2 +- .../lib/src/document/document_repository.dart | 63 +- .../database_documents_data_source.dart | 139 ++- .../source/database_drafts_data_source.dart | 160 ++- .../source/document_data_local_source.dart | 81 +- .../source/document_data_remote_source.dart | 6 +- .../document/source/document_data_source.dart | 3 +- .../local_document_data_local_source.dart | 16 + .../signed_document_data_local_source.dart | 24 + .../lib/src/proposal/proposal_repository.dart | 2 +- .../database/dao/documents_v2_dao_test.dart | 1071 ++++++++++++++++- .../lib/src/proposal/proposal_service.dart | 4 +- .../src/proposal/proposal_service_test.dart | 1 + 20 files changed, 1964 insertions(+), 371 deletions(-) create mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/local_draft_documents_v2_dao.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/workspace_dao.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart diff --git a/catalyst_voices/apps/voices/lib/configs/bootstrap.dart b/catalyst_voices/apps/voices/lib/configs/bootstrap.dart index 9b75a87b7659..a1a9ee53d5ae 100644 --- a/catalyst_voices/apps/voices/lib/configs/bootstrap.dart +++ b/catalyst_voices/apps/voices/lib/configs/bootstrap.dart @@ -182,7 +182,7 @@ Future cleanUpStorages({ Future cleanUpUserDataFromDatabase() async { final db = Dependencies.instance.get(); - await db.workspaceDao.deleteLocalDrafts(); + await db.localDocumentsV2Dao.deleteWhere(); await db.localMetadataDao.deleteWhere(); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_ref.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_ref.dart index fffb38acfda4..f105d5e6712a 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_ref.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_ref.dart @@ -42,6 +42,8 @@ sealed class DocumentRef extends Equatable implements Comparable { /// Whether the ref specifies the document [version]. bool get isExact => version != null; + bool get isLoose => !isExact; + @override List get props => [id, version]; diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart index d7bdd14caad8..084b72f8b2b2 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart @@ -18,6 +18,8 @@ export 'document/source/database_drafts_data_source.dart'; export 'document/source/document_data_local_source.dart'; export 'document/source/document_data_remote_source.dart'; export 'document/source/document_data_source.dart'; +export 'document/source/local_document_data_local_source.dart'; +export 'document/source/signed_document_data_local_source.dart'; export 'dto/document/document_dto.dart' show DocumentExt; export 'logging/logging_settings_storage.dart'; export 'proposal/proposal_repository.dart' show ProposalRepository; diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/catalyst_database.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/catalyst_database.dart index 59c74b6a5f8e..493119bd6e82 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/catalyst_database.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/catalyst_database.dart @@ -2,8 +2,8 @@ import 'package:catalyst_voices_repositories/src/database/catalyst_database.drif import 'package:catalyst_voices_repositories/src/database/catalyst_database_config.dart'; import 'package:catalyst_voices_repositories/src/database/dao/documents_v2_dao.dart'; import 'package:catalyst_voices_repositories/src/database/dao/documents_v2_local_metadata_dao.dart'; +import 'package:catalyst_voices_repositories/src/database/dao/local_draft_documents_v2_dao.dart'; import 'package:catalyst_voices_repositories/src/database/dao/proposals_v2_dao.dart'; -import 'package:catalyst_voices_repositories/src/database/dao/workspace_dao.dart'; import 'package:catalyst_voices_repositories/src/database/migration/drift_migration_strategy.dart'; import 'package:catalyst_voices_repositories/src/database/table/document_authors.dart'; import 'package:catalyst_voices_repositories/src/database/table/documents_local_metadata.dart'; @@ -28,6 +28,8 @@ abstract interface class CatalystDatabase { DocumentsV2Dao get documentsV2Dao; + LocalDraftDocumentsV2Dao get localDocumentsV2Dao; + DocumentsV2LocalMetadataDao get localMetadataDao; /// Allows to await completion of pending operations. @@ -38,8 +40,6 @@ abstract interface class CatalystDatabase { ProposalsV2Dao get proposalsV2Dao; - WorkspaceDao get workspaceDao; - Future analyze(); /// Removes all data from this db. @@ -62,7 +62,7 @@ abstract interface class CatalystDatabase { DriftDocumentsV2Dao, DriftProposalsV2Dao, DriftDocumentsV2LocalMetadataDao, - DriftWorkspaceDao, + DriftLocalDraftDocumentsV2Dao, ], queries: {}, views: [], @@ -100,6 +100,9 @@ class DriftCatalystDatabase extends $DriftCatalystDatabase implements CatalystDa @override DocumentsV2Dao get documentsV2Dao => driftDocumentsV2Dao; + @override + LocalDraftDocumentsV2Dao get localDocumentsV2Dao => driftLocalDraftDocumentsV2Dao; + @override DocumentsV2LocalMetadataDao get localMetadataDao => driftDocumentsV2LocalMetadataDao; @@ -123,9 +126,6 @@ class DriftCatalystDatabase extends $DriftCatalystDatabase implements CatalystDa @override int get schemaVersion => 4; - @override - WorkspaceDao get workspaceDao => driftWorkspaceDao; - @override Future analyze() async { await customStatement('ANALYZE'); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart index 1ee9d41d65f0..115b9b85af7e 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart @@ -9,94 +9,128 @@ import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; abstract interface class DocumentsV2Dao { - /// Returns the total number of documents in the table. - Future count(); + /// Counts the number of documents matching the provided filters. + /// + /// [type] filters by the document type (e.g., proposal, comment). + /// [ref] filters by the document's own identity. + /// - If [DocumentRef.isExact], counts matches for that specific version. + /// - If [DocumentRef.isLoose], counts all versions of that document ID. + /// [refTo] filters documents that *reference* the given target. + /// - Example: Count all comments ([type]=comment) that point to proposal X ([refTo]=X). + Future count({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }); - /// Deletes documents that meet a specific condition and returns the number of - /// documents deleted. + /// Deletes documents from the database, preserving those with types in [notInType]. + /// + /// If [notInType] is null or empty, this may delete *all* documents (implementation dependent). + /// Typically used for cache invalidation or cleaning up old data while keeping + /// certain important types (e.g. keeping local drafts or templates). /// - /// This method is intended to be implemented by a concrete class that defines - /// the deletion criteria. For example, it could delete all documents that are - /// older than a certain date. + /// Returns the number of deleted rows. Future deleteWhere({ List? notInType, }); - /// Checks if a document exists by its reference. + /// Checks if a document exists in the database. /// - /// If [ref] is exact (has version), checks for the specific version. - /// If loose (no version), checks if any version with the id exists. - /// Returns true if the document exists, false otherwise. + /// [ref] determines the scope of the check: + /// - [SignedDocumentRef.exact]: Returns true only if that specific version exists. + /// - [SignedDocumentRef.loose]: Returns true if *any* version of that ID exists. Future exists(DocumentRef ref); - /// Filters and returns only the DocumentRefs from [refs] that exist in the database. + /// Filters a list of references, returning only those that exist in the database. /// - /// Optimized for performance: Uses a single query to fetch all relevant (id, ver) pairs - /// for unique ids in [refs], then checks existence in memory. - /// - For exact refs: Matches specific id and ver. - /// - For loose refs: Checks if any version for the id exists. - /// Suitable for synchronizing many documents with minimal database round-trips. + /// This is useful for bulk validation. + /// - For exact refs, it checks for exact matches. + /// - For loose refs, it checks if any version of the ID exists. Future> filterExisting(List refs); - /// Retrieves a document. + /// Retrieves a single document matching the criteria. /// - /// If [ref] is provided and is exact (has version), returns the specific version. - /// If [ref] is loose (no version), returns the latest version by createdAt. - /// If [author] is non null tries to find matching document - /// Returns null if no matching document is found. + /// If multiple documents match (e.g. querying by loose ref or type only), + /// the one with the latest [DocumentEntityV2.createdAt] timestamp is returned. + /// + /// Returns `null` if no matching document is found. Future getDocument({ + DocumentType? type, DocumentRef? ref, + DocumentRef? refTo, CatalystId? author, }); - /// Retrieves a list of documents that match the given criteria. + /// Retrieves a list of documents matching the criteria with pagination. /// - /// This method returns a list of documents that match the given criteria. - /// - [id]: optional filter to only include documents with matching id. - /// - [type]: Optional filter to only include documents of a specific [DocumentType]. - /// - [filters]: Optional campaign filter. - /// - [latestOnly] is true only newest version per id is returned. - /// - [limit]: The maximum number of documents to return. - /// - [offset]: The number of documents to skip for pagination. + /// [latestOnly] - If `true`, only the most recent version (by [DocumentEntityV2.createdAt]) + /// of each unique document ID is returned. If `false`, all versions are returned. + /// [limit] - The maximum number of documents to return (clamped to 999). + /// [offset] - The number of documents to skip. + /// + /// Note: Ensure the implementation applies a deterministic `orderBy` clause + /// (usually `createdAt` DESC) to ensure stable pagination. Future> getDocuments({ - String? id, DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, CampaignFilters? filters, bool latestOnly, int limit, int offset, }); - /// Finds the latest version of a document. + /// Finds the latest version of a document given a reference. /// - /// Takes a [ref] (which can be loose or exact) and returns a [DocumentRef] - /// pointing to the latest known version of that document. + /// Even if [ref] points to an older version (exact), this method will find + /// the version with the newest [DocumentEntityV2.createdAt] timestamp for that [DocumentRef.id]. + /// + /// Returns `null` if the document ID does not exist in the database. Future getLatestOf(DocumentRef ref); - /// Saves a single document, ignoring if it conflicts on {id, ver}. + /// Saves a single document and its associated authors. /// - /// Delegates to [saveAll] for consistent conflict handling and reuse. + /// This is a convenience wrapper around [saveAll]. Future save(DocumentWithAuthorsEntity entity); - /// Saves multiple documents in a batch operation, ignoring conflicts. + /// Saves multiple documents and their authors in a single transaction. /// - /// [entries] is a list of DocumentEntity instances. - /// Uses insertOrIgnore to skip on primary key conflicts ({id, ver}). + /// Uses `INSERT OR IGNORE` conflict resolution. If a document with the same + /// `id` and `ver` already exists, the new record is ignored. Future saveAll(List entries); - /// Watches for a list of documents that match the given criteria. + /// Watches for changes and emits the count of documents matching the filters. + /// + /// Emits a new value whenever the underlying tables change in a way that + /// affects the count. + Stream watchCount({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }); + + /// Watches for changes to a specific document query. + /// + /// Emits the updated document (or null) whenever a matching record is + /// inserted, updated, or deleted. + Stream watchDocument({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + CatalystId? author, + }); + + /// Watches for changes and emits a list of documents. + /// + /// This stream automatically updates when new documents are synced or + /// existing ones are modified. /// - /// This method returns a stream that emits a new list of documents whenever - /// the underlying data changes. - /// - [id]: optional filter to only include documents with matching id. - /// - [type]: Optional filter to only include documents of a specific [DocumentType]. - /// - [filters]: Optional campaign filter. - /// - [latestOnly] is true only newest version per id is returned. - /// - [limit]: The maximum number of documents to return. - /// - [offset]: The number of documents to skip for pagination. + /// Note: Large limits or complex filters in a watch stream can impact performance + /// as the query is re-run on every write to the `documents_v2` table. Stream> watchDocuments({ - String? id, DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, CampaignFilters? filters, bool latestOnly, int limit, @@ -116,8 +150,12 @@ class DriftDocumentsV2Dao extends DatabaseAccessor DriftDocumentsV2Dao(super.attachedDatabase); @override - Future count() { - return documentsV2.count().getSingleOrNull().then((value) => value ?? 0); + Future count({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }) { + return _queryCount(type: type, ref: ref, refTo: refTo).getSingle().then((value) => value ?? 0); } @override @@ -186,52 +224,28 @@ class DriftDocumentsV2Dao extends DatabaseAccessor @override Future getDocument({ + DocumentType? type, DocumentRef? ref, + DocumentRef? refTo, CatalystId? author, }) { - final query = select(documentsV2); - - if (ref != null) { - query.where((tbl) => tbl.id.equals(ref.id)); - - if (ref.isExact) { - query.where((tbl) => tbl.ver.equals(ref.version!)); - } - } - - // TODO(damian-molinski): test it - if (author != null) { - final significant = author.toSignificant().toUri().toString(); - query.where((tbl) { - final authorDocs = subqueryExpression( - selectOnly(documentAuthors) - ..addColumns([documentAuthors.documentId]) - ..where(documentAuthors.authorIdSignificant.equals(significant)) - ..where(documentAuthors.documentId.equalsExp(documentsV2.id)), - ); - return tbl.id.equalsExp(authorDocs); - }); - } - - query - ..orderBy([(tbl) => OrderingTerm.desc(tbl.createdAt)]) - ..limit(1); - - return query.getSingleOrNull(); + return _queryDocument(type: type, ref: ref, refTo: refTo, author: author).getSingleOrNull(); } @override Future> getDocuments({ - String? id, DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, CampaignFilters? filters, bool latestOnly = false, int limit = 200, int offset = 0, }) { return _queryDocuments( - id: id, type: type, + ref: ref, + refTo: refTo, filters: filters, latestOnly: latestOnly, limit: limit, @@ -284,18 +298,39 @@ class DriftDocumentsV2Dao extends DatabaseAccessor }); } + @override + Stream watchCount({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }) { + return _queryCount(type: type, ref: ref, refTo: refTo).watchSingle().map((value) => value ?? 0); + } + + @override + Stream watchDocument({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + CatalystId? author, + }) { + return _queryDocument(type: type, ref: ref, refTo: refTo, author: author).watchSingleOrNull(); + } + @override Stream> watchDocuments({ - String? id, DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, CampaignFilters? filters, bool latestOnly = false, int limit = 200, int offset = 0, }) { return _queryDocuments( - id: id, type: type, + ref: ref, + refTo: refTo, filters: filters, latestOnly: latestOnly, limit: limit, @@ -303,31 +338,123 @@ class DriftDocumentsV2Dao extends DatabaseAccessor ).watch(); } + Selectable _queryCount({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }) { + final count = countAll(); + final query = selectOnly(documentsV2)..addColumns([count]); + + if (type != null) { + query.where(documentsV2.type.equalsValue(type)); + } + + if (ref != null) { + query.where(documentsV2.id.equals(ref.id)); + + if (ref.isExact) { + query.where(documentsV2.ver.equals(ref.version!)); + } + } + + if (refTo != null) { + query.where(documentsV2.refId.equals(refTo.id)); + + if (refTo.isExact) { + query.where(documentsV2.refVer.equals(refTo.version!)); + } + } + + return query.map((row) => row.read(count)); + } + + Selectable _queryDocument({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + CatalystId? author, + }) { + final query = select(documentsV2); + + if (ref != null) { + query.where((tbl) => tbl.id.equals(ref.id)); + + if (ref.isExact) { + query.where((tbl) => tbl.ver.equals(ref.version!)); + } + } + + if (refTo != null) { + query.where((tbl) => tbl.refId.equals(refTo.id)); + + if (refTo.isExact) { + query.where((tbl) => tbl.refVer.equals(refTo.version!)); + } + } + + if (type != null) { + query.where((tbl) => tbl.type.equalsValue(type)); + } + + if (author != null) { + final significant = author.toSignificant(); + query.where((tbl) { + final authorQuery = selectOnly(documentAuthors) + ..addColumns([const Constant(1)]) + ..where(documentAuthors.documentId.equalsExp(tbl.id)) + ..where(documentAuthors.documentVer.equalsExp(tbl.ver)) + ..where(documentAuthors.authorIdSignificant.equals(significant.toUri().toString())); + return existsQuery(authorQuery); + }); + } + + query + ..orderBy([ + (tbl) => OrderingTerm.desc(tbl.createdAt), + ]) + ..limit(1); + + return query; + } + SimpleSelectStatement<$DocumentsV2Table, DocumentEntityV2> _queryDocuments({ - String? id, DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, CampaignFilters? filters, required bool latestOnly, required int limit, required int offset, }) { final effectiveLimit = limit.clamp(0, 999); - final query = select(documentsV2); - if (id != null) { - query.where((tbl) => tbl.id.equals(id)); + if (type != null) { + query.where((tbl) => tbl.type.equalsValue(type)); } - if (filters != null) { - query.where((tbl) => tbl.categoryId.isIn(filters.categoriesIds)); + if (ref != null) { + query.where((tbl) => tbl.id.equals(ref.id)); + + if (ref.isExact) { + query.where((tbl) => tbl.ver.equals(ref.version!)); + } } - if (type != null) { - query.where((tbl) => tbl.type.equalsValue(type)); + if (refTo != null) { + query.where((tbl) => tbl.refId.equals(refTo.id)); + + if (refTo.isExact) { + query.where((tbl) => tbl.refVer.equals(refTo.version!)); + } + } + + if (filters != null) { + query.where((tbl) => tbl.categoryId.isIn(filters.categoriesIds)); } - if (latestOnly) { + if (latestOnly && ref?.version == null) { final inner = alias(documentsV2, 'inner'); query.where((tbl) { @@ -340,7 +467,9 @@ class DriftDocumentsV2Dao extends DatabaseAccessor }); } - query.limit(effectiveLimit, offset: offset); + query + ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]) + ..limit(effectiveLimit, offset: offset); return query; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/local_draft_documents_v2_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/local_draft_documents_v2_dao.dart new file mode 100644 index 000000000000..3b7e615ebb81 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/local_draft_documents_v2_dao.dart @@ -0,0 +1,394 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; +import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; +import 'package:catalyst_voices_repositories/src/database/dao/local_draft_documents_v2_dao.drift.dart'; +import 'package:catalyst_voices_repositories/src/database/table/local_documents_drafts.dart'; +import 'package:catalyst_voices_repositories/src/database/table/local_documents_drafts.drift.dart'; +import 'package:drift/drift.dart'; + +@DriftAccessor( + tables: [ + LocalDocumentsDrafts, + ], +) +class DriftLocalDraftDocumentsV2Dao extends DatabaseAccessor + with $DriftLocalDraftDocumentsV2DaoMixin + implements LocalDraftDocumentsV2Dao { + DriftLocalDraftDocumentsV2Dao(super.attachedDatabase); + + @override + Future count({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }) { + return _queryCount(type: type, ref: ref, refTo: refTo).getSingle().then((value) => value ?? 0); + } + + @override + Future deleteWhere({ + DocumentRef? ref, + List? notInType, + }) { + final query = delete(localDocumentsDrafts); + + if (notInType != null) { + query.where((tbl) => tbl.type.isNotInValues(notInType)); + } + + return query.go(); + } + + @override + Future exists(DocumentRef ref) { + final query = selectOnly(localDocumentsDrafts) + ..addColumns([const Constant(1)]) + ..where(localDocumentsDrafts.id.equals(ref.id)); + + if (ref.isExact) { + query.where(localDocumentsDrafts.ver.equals(ref.version!)); + } + + query.limit(1); + + return query.getSingleOrNull().then((result) => result != null); + } + + @override + Future> filterExisting(List refs) async { + if (refs.isEmpty) return []; + + final uniqueIds = refs.map((ref) => ref.id).toSet(); + + // Single query: Fetch all (id, ver) for matching ids + final query = selectOnly(localDocumentsDrafts) + ..addColumns([localDocumentsDrafts.id, localDocumentsDrafts.ver]) + ..where(localDocumentsDrafts.id.isIn(uniqueIds)); + + final rows = await query.map( + (row) { + final id = row.read(localDocumentsDrafts.id)!; + final ver = row.read(localDocumentsDrafts.ver)!; + return (id: id, ver: ver); + }, + ).get(); + + final idToVers = >{}; + for (final pair in rows) { + idToVers.update( + pair.id, + (value) => value..add(pair.ver), + ifAbsent: () => {pair.ver}, + ); + } + + return refs.where((ref) { + final vers = idToVers[ref.id]; + if (vers == null || vers.isEmpty) return false; + + return !ref.isExact || vers.contains(ref.version); + }).toList(); + } + + @override + Future getDocument({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }) { + return _queryDocument(type: type, ref: ref, refTo: refTo).getSingleOrNull(); + } + + @override + Future> getDocuments({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + CampaignFilters? filters, + bool latestOnly = false, + int limit = 200, + int offset = 0, + }) { + return _queryDocuments( + type: type, + ref: ref, + refTo: refTo, + filters: filters, + latestOnly: latestOnly, + limit: limit, + offset: offset, + ).get(); + } + + @override + Future getLatestOf(DocumentRef ref) { + final query = selectOnly(localDocumentsDrafts) + ..addColumns([localDocumentsDrafts.id, localDocumentsDrafts.ver]) + ..where(localDocumentsDrafts.id.equals(ref.id)) + ..orderBy([OrderingTerm.desc(localDocumentsDrafts.createdAt)]) + ..limit(1); + + return query + .map( + (row) => DraftRef( + id: row.read(localDocumentsDrafts.id)!, + version: row.read(localDocumentsDrafts.ver), + ), + ) + .getSingleOrNull(); + } + + @override + Future saveAll(List entries) async { + await batch((batch) { + batch.insertAll( + localDocumentsDrafts, + entries, + mode: InsertMode.insertOrReplace, + ); + }); + } + + @override + Future updateContent({ + required DocumentRef ref, + required DocumentDataContent content, + }) async { + final insertable = LocalDocumentsDraftsCompanion(content: Value(content)); + + final query = update(localDocumentsDrafts)..where((tbl) => tbl.id.equals(ref.id)); + + if (ref.isExact) { + query.where((tbl) => tbl.ver.equals(ref.version!)); + } + + await query.write(insertable); + } + + @override + Stream watchCount({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }) { + return _queryCount(type: type, ref: ref, refTo: refTo).watchSingle().map((value) => value ?? 0); + } + + @override + Stream watchDocument({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }) { + return _queryDocument(type: type, ref: ref, refTo: refTo).watchSingleOrNull(); + } + + @override + Stream> watchDocuments({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + CampaignFilters? filters, + bool latestOnly = false, + int limit = 200, + int offset = 0, + }) { + return _queryDocuments( + type: type, + ref: ref, + refTo: refTo, + filters: filters, + latestOnly: latestOnly, + limit: limit, + offset: offset, + ).watch(); + } + + Selectable _queryCount({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }) { + final count = countAll(); + final query = selectOnly(localDocumentsDrafts)..addColumns([count]); + + if (type != null) { + query.where(localDocumentsDrafts.type.equalsValue(type)); + } + + if (ref != null) { + query.where(localDocumentsDrafts.id.equals(ref.id)); + + if (ref.isExact) { + query.where(localDocumentsDrafts.ver.equals(ref.version!)); + } + } + + if (refTo != null) { + query.where(localDocumentsDrafts.refId.equals(refTo.id)); + + if (refTo.isExact) { + query.where(localDocumentsDrafts.refVer.equals(refTo.version!)); + } + } + + return query.map((row) => row.read(count)); + } + + Selectable _queryDocument({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }) { + final query = select(localDocumentsDrafts); + + if (ref != null) { + query.where((tbl) => tbl.id.equals(ref.id)); + + if (ref.isExact) { + query.where((tbl) => tbl.ver.equals(ref.version!)); + } + } + + if (refTo != null) { + query.where((tbl) => tbl.refId.equals(refTo.id)); + + if (refTo.isExact) { + query.where((tbl) => tbl.refVer.equals(refTo.version!)); + } + } + + if (type != null) { + query.where((tbl) => tbl.type.equalsValue(type)); + } + + query + ..orderBy([ + (tbl) => OrderingTerm.desc(tbl.createdAt), + ]) + ..limit(1); + + return query; + } + + SimpleSelectStatement<$LocalDocumentsDraftsTable, LocalDocumentDraftEntity> _queryDocuments({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + CampaignFilters? filters, + required bool latestOnly, + required int limit, + required int offset, + }) { + final effectiveLimit = limit.clamp(0, 999); + final query = select(localDocumentsDrafts); + + if (type != null) { + query.where((tbl) => tbl.type.equalsValue(type)); + } + + if (ref != null) { + query.where((tbl) => tbl.id.equals(ref.id)); + + if (ref.isExact) { + query.where((tbl) => tbl.ver.equals(ref.version!)); + } + } + + if (refTo != null) { + query.where((tbl) => tbl.refId.equals(refTo.id)); + + if (refTo.isExact) { + query.where((tbl) => tbl.refVer.equals(refTo.version!)); + } + } + + if (filters != null) { + query.where((tbl) => tbl.categoryId.isIn(filters.categoriesIds)); + } + + if (latestOnly && ref?.version == null) { + final inner = alias(localDocumentsDrafts, 'inner'); + + query.where((tbl) { + final maxCreatedAt = subqueryExpression( + selectOnly(inner) + ..addColumns([inner.createdAt.max()]) + ..where(inner.id.equalsExp(tbl.id)), + ); + return tbl.createdAt.equalsExp(maxCreatedAt); + }); + } + + query + ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]) + ..limit(effectiveLimit, offset: offset); + + return query; + } +} + +/// This interface is very similar to [DocumentsV2Dao] so see methods explanation there. +abstract interface class LocalDraftDocumentsV2Dao { + Future count({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }); + + Future deleteWhere({ + DocumentRef? ref, + List? notInType, + }); + + Future exists(DocumentRef ref); + + Future> filterExisting(List refs); + + Future getDocument({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }); + + Future> getDocuments({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + CampaignFilters? filters, + bool latestOnly, + int limit, + int offset, + }); + + Future getLatestOf(DocumentRef ref); + + Future saveAll(List entries); + + Future updateContent({ + required DocumentRef ref, + required DocumentDataContent content, + }); + + Stream watchCount({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }); + + Stream watchDocument({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }); + + Stream> watchDocuments({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + CampaignFilters? filters, + bool latestOnly, + int limit, + int offset, + }); +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/workspace_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/workspace_dao.dart deleted file mode 100644 index 895122ed905b..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/workspace_dao.dart +++ /dev/null @@ -1,32 +0,0 @@ -//ignore_for_file: one_member_abstracts - -import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; -import 'package:catalyst_voices_repositories/src/database/dao/workspace_dao.drift.dart'; -import 'package:catalyst_voices_repositories/src/database/table/documents_local_metadata.dart'; -import 'package:catalyst_voices_repositories/src/database/table/documents_v2.dart'; -import 'package:catalyst_voices_repositories/src/database/table/local_documents_drafts.dart'; -import 'package:drift/drift.dart'; - -@DriftAccessor( - tables: [ - DocumentsV2, - LocalDocumentsDrafts, - DocumentsLocalMetadata, - ], -) -class DriftWorkspaceDao extends DatabaseAccessor - with $DriftWorkspaceDaoMixin - implements WorkspaceDao { - DriftWorkspaceDao(super.attachedDatabase); - - @override - Future deleteLocalDrafts() { - final query = delete(localDocumentsDrafts); - - return query.go(); - } -} - -abstract interface class WorkspaceDao { - Future deleteLocalDrafts(); -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/database.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/database.dart index 361d39b67b70..d5fee119da88 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/database.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/database.dart @@ -2,5 +2,5 @@ export 'catalyst_database.dart' show CatalystDatabase; export 'catalyst_database_config.dart'; export 'dao/documents_v2_dao.dart' show DocumentsV2Dao; export 'dao/documents_v2_local_metadata_dao.dart' show DocumentsV2LocalMetadataDao; +export 'dao/local_draft_documents_v2_dao.dart' show LocalDraftDocumentsV2Dao; export 'dao/proposals_v2_dao.dart' show ProposalsV2Dao; -export 'dao/workspace_dao.dart' show WorkspaceDao; diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart index c2f83f16d68a..bb6f8539ea94 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart @@ -87,9 +87,9 @@ abstract interface class DocumentRepository { /// Returns latest matching [DocumentRef] version with same id as [ref]. Future getLatestOf({required DocumentRef ref}); - /// Returns count of documents matching [ref] id and [type]. + /// Returns count of documents matching [refTo] id and [type]. Future getRefCount({ - required DocumentRef ref, + required DocumentRef refTo, required DocumentType type, }); @@ -256,8 +256,8 @@ final class DocumentRepositoryImpl implements DocumentRepository { @override Future> findAllVersions({required DocumentRef ref}) async { final all = switch (ref) { - DraftRef() => await _drafts.findAllVersions(ref: ref), - SignedDocumentRef() => await _localDocuments.findAllVersions(ref: ref), + DraftRef() => await _drafts.getAll(ref: ref), + SignedDocumentRef() => await _localDocuments.getAll(ref: ref), }..sort(); return all; @@ -267,8 +267,8 @@ final class DocumentRepositoryImpl implements DocumentRepository { Future> getAllVersionsOfId({ required String id, }) async { - final localRefs = await _localDocuments.queryVersionsOfId(id: id); - final drafts = await _drafts.queryVersionsOfId(id: id); + final localRefs = await _localDocuments.getAll(ref: SignedDocumentRef(id: id)); + final drafts = await _drafts.getAll(ref: DraftRef(id: id)); return [...drafts, ...localRefs]; } @@ -278,22 +278,28 @@ final class DocumentRepositoryImpl implements DocumentRepository { required DocumentRef ref, bool useCache = true, }) async { - return switch (ref) { - SignedDocumentRef() => _getSignedDocumentData(ref: ref, useCache: useCache), + final documentData = switch (ref) { + SignedDocumentRef() => await _getSignedDocumentData(ref: ref, useCache: useCache), DraftRef() when !useCache => throw DocumentNotFoundException( ref: ref, message: '$ref can not be resolved while not using cache', ), - DraftRef() => _getDraftDocumentData(ref: ref), + DraftRef() => await _getDraftDocumentData(ref: ref), }; + + if (documentData == null) { + throw DocumentNotFoundException(ref: ref); + } + + return documentData; } @override Future getLatestDocument({ CatalystId? authorId, }) async { - final latestDocument = await _localDocuments.getLatest(authorId: authorId); - final latestDraft = await _drafts.getLatest(authorId: authorId); + final latestDocument = await _localDocuments.get(authorId: authorId); + final latestDraft = await _drafts.get(); return [latestDocument, latestDraft].nonNulls.sorted((a, b) => a.compareTo(b)).firstOrNull; } @@ -311,10 +317,10 @@ final class DocumentRepositoryImpl implements DocumentRepository { @override Future getRefCount({ - required DocumentRef ref, + required DocumentRef refTo, required DocumentType type, }) { - return _localDocuments.getRefCount(ref: ref, type: type); + return _localDocuments.count(refTo: refTo, type: type); } @override @@ -322,7 +328,7 @@ final class DocumentRepositoryImpl implements DocumentRepository { required DocumentRef refTo, required DocumentType type, }) { - return _localDocuments.getRefToDocumentData(refTo: refTo, type: type); + return _localDocuments.get(refTo: refTo, type: type); } @override @@ -354,8 +360,7 @@ final class DocumentRepositoryImpl implements DocumentRepository { @override Future isFavorite(DocumentRef ref) { - // TODO: implement isFavorite - throw UnimplementedError(); + return _db.localMetadataDao.isFavorite(ref.id); } @override @@ -379,9 +384,9 @@ final class DocumentRepositoryImpl implements DocumentRepository { bool includeLocalDrafts = false, }) async { List documents; - final localDocuments = await _localDocuments.queryVersionsOfId(id: id); + final localDocuments = await _localDocuments.getAll(ref: SignedDocumentRef(id: id)); if (includeLocalDrafts) { - final localDrafts = await _drafts.queryVersionsOfId(id: id); + final localDrafts = await _drafts.getAll(ref: DraftRef(id: id)); documents = [...localDocuments, ...localDrafts]; } else { documents = localDocuments; @@ -404,10 +409,9 @@ final class DocumentRepositoryImpl implements DocumentRepository { Future removeAll({ bool keepLocalDrafts = false, }) async { - final deletedDrafts = keepLocalDrafts ? 0 : await _drafts.deleteAll(); - final deletedDocuments = await _localDocuments.deleteAll( - notInType: keepLocalDrafts ? [DocumentType.proposalTemplate] : null, - ); + final deletedDrafts = keepLocalDrafts ? 0 : await _drafts.delete(); + final notInType = keepLocalDrafts ? [DocumentType.proposalTemplate] : null; + final deletedDocuments = await _localDocuments.delete(notInType: notInType); return deletedDrafts + deletedDocuments; } @@ -468,11 +472,11 @@ final class DocumentRepositoryImpl implements DocumentRepository { }) { final localDocs = _localDocuments .watchAll( - limit: limit, - unique: unique, + latestOnly: unique, type: type, authorId: authorId, refTo: refTo, + limit: limit ?? 200, ) .asyncMap( (documents) async => _processDocuments( @@ -487,9 +491,8 @@ final class DocumentRepositoryImpl implements DocumentRepository { final localDrafts = _drafts .watchAll( - limit: limit, type: type, - authorId: authorId, + limit: limit ?? 100, ) .asyncMap( (documents) async => _processDocuments( @@ -605,16 +608,16 @@ final class DocumentRepositoryImpl implements DocumentRepository { required DocumentType type, ValueResolver refGetter = _templateResolver, }) { - return _localDocuments.watchRefToDocumentData(refTo: refTo, type: type).distinct(); + return _localDocuments.watch(refTo: refTo, type: type).distinct(); } - Future _getDraftDocumentData({ + Future _getDraftDocumentData({ required DraftRef ref, }) async { return _drafts.get(ref: ref); } - Future _getSignedDocumentData({ + Future _getSignedDocumentData({ required SignedDocumentRef ref, bool useCache = true, }) async { @@ -633,7 +636,7 @@ final class DocumentRepositoryImpl implements DocumentRepository { final document = await _remoteDocuments.get(ref: ref); - if (useCache) { + if (useCache && document != null) { await _localDocuments.save(data: document); } 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 bceb228a2818..881ac5e99a85 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 @@ -24,7 +24,16 @@ final class DatabaseDocumentsDataSource ); @override - Future deleteAll({ + Future count({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }) { + return _database.documentsV2Dao.count(type: type, ref: ref, refTo: refTo); + } + + @override + Future delete({ List? notInType, }) { return _database.documentsV2Dao.deleteWhere(notInType: notInType); @@ -41,27 +50,36 @@ final class DatabaseDocumentsDataSource } @override - Future> findAllVersions({required DocumentRef ref}) { + Future get({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + CatalystId? authorId, + }) { return _database.documentsV2Dao - .getDocuments(id: ref.id) - .then((value) => value.map((e) => e.toModel()).toList()); - } - - @override - Future get({required DocumentRef ref}) async { - final entity = await _database.documentsV2Dao.getDocument(ref: ref); - if (entity == null) { - throw DocumentNotFoundException(ref: ref); - } - - return entity.toModel(); + .getDocument(type: type, ref: ref, refTo: refTo, author: authorId) + .then((value) => value?.toModel()); } @override - Future getLatest({ - CatalystId? authorId, + Future> getAll({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + bool latestOnly = false, + int limit = 200, + int offset = 0, }) { - return _database.documentsV2Dao.getDocument(author: authorId).then((value) => value?.toModel()); + return _database.documentsV2Dao + .getDocuments( + type: type, + ref: ref, + refTo: refTo, + latestOnly: latestOnly, + limit: limit, + offset: offset, + ) + .then((value) => value.map((e) => e.toModel()).toList()); } @override @@ -77,30 +95,6 @@ final class DatabaseDocumentsDataSource return _database.proposalsV2Dao.getProposalsTotalTask(filters: filters, nodeId: nodeId); } - @override - Future getRefCount({ - required DocumentRef ref, - required DocumentType type, - }) { - return _database.documentsDao.countRefDocumentByType(ref: ref, type: type); - } - - @override - Future getRefToDocumentData({ - required DocumentRef refTo, - DocumentType? type, - }) { - return _database.documentsDao - .queryRefToDocumentData(refTo: refTo, type: type) - .then((e) => e?.toModel()); - } - - @override - Future> queryVersionsOfId({required String id}) async { - final documentEntities = await _database.documentsDao.queryVersionsOfId(id: id); - return documentEntities.map((e) => e.toModel()).toList(); - } - @override Future save({required DocumentData data}) => saveAll([data]); @@ -122,40 +116,53 @@ final class DatabaseDocumentsDataSource } @override - Stream watch({required DocumentRef ref}) { - return _database.documentsDao.watch(ref: ref).map((entity) => entity?.toModel()); + Stream watch({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }) { + return _database.documentsV2Dao + .watchDocument(type: type, ref: ref, refTo: refTo) + .distinct() + .map((value) => value?.toModel()); } @override Stream> watchAll({ - int? limit, - required bool unique, DocumentType? type, - CatalystId? authorId, + DocumentRef? ref, DocumentRef? refTo, + CatalystId? authorId, + bool latestOnly = false, + int limit = 200, + int offset = 0, }) { - return _database.documentsDao - .watchAll( - limit: limit, - unique: unique, + return _database.documentsV2Dao + .watchDocuments( type: type, - authorId: authorId, + ref: ref, refTo: refTo, + latestOnly: latestOnly, + limit: limit, + offset: offset, ) - .map((entities) { - return List.from(entities.map((e) => e.toModel())); - }); + .distinct(listEquals) + .map((value) => value.map((e) => e.toModel()).toList()); } @override Stream watchCount({ - DocumentRef? refTo, DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, }) { - return _database.documentsDao.watchCount( - refTo: refTo, - type: type, - ); + return _database.documentsV2Dao + .watchCount( + type: type, + ref: ref, + refTo: refTo, + ) + .distinct(); } @override @@ -173,6 +180,7 @@ final class DatabaseDocumentsDataSource if (!tr.finished) unawaited(tr.finish()); }, ) + .distinct() .map((page) => page.map((data) => data.toModel())); } @@ -186,7 +194,7 @@ final class DatabaseDocumentsDataSource (_) { if (!tr.finished) unawaited(tr.finish()); }, - ); + ).distinct(); } @override @@ -208,16 +216,6 @@ final class DatabaseDocumentsDataSource .distinct(listEquals) .map((event) => event.map((e) => e.toModel()).toList()); } - - @override - Stream watchRefToDocumentData({ - required DocumentRef refTo, - required DocumentType type, - }) { - return _database.documentsDao - .watchRefToDocumentData(refTo: refTo, type: type) - .map((e) => e?.toModel()); - } } extension on DocumentEntityV2 { @@ -231,7 +229,6 @@ extension on DocumentEntityV2 { reply: replyId.toRef(replyVer), section: section, categoryId: categoryId.toRef(categoryVer), - // TODO(damian-molinski): Make sure to add unit tests authors: authors.isEmpty ? null : authors.split(',').map(CatalystId.parse).toList(), ), content: content, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart index a8247febb81a..308b8abc5a13 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart @@ -1,5 +1,8 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; +import 'package:catalyst_voices_repositories/src/database/table/local_documents_drafts.drift.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/foundation.dart'; /// Encryption will be added later here as drafts are not public final class DatabaseDraftsDataSource implements DraftDataSource { @@ -10,56 +13,67 @@ final class DatabaseDraftsDataSource implements DraftDataSource { ); @override - Future delete({required DraftRef ref}) async { - await _database.draftsDao.deleteWhere(ref: ref); + Future count({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }) { + return _database.localDocumentsV2Dao.count(type: type, ref: ref, refTo: refTo); } @override - Future deleteAll() => _database.draftsDao.deleteAll(); + Future delete({ + DocumentRef? ref, + List? notInType, + }) { + return _database.localDocumentsV2Dao.deleteWhere(ref: ref, notInType: notInType); + } @override Future exists({required DocumentRef ref}) { - return _database.draftsDao.count(ref: ref).then((count) => count > 0); + return _database.localDocumentsV2Dao.exists(ref); } @override Future> filterExisting(List refs) { - // TODO(damian-molinski): not implemented - return Future(() => []); + return _database.localDocumentsV2Dao.filterExisting(refs); } @override - Future get({required DocumentRef ref}) async { - final entity = await _database.draftsDao.query(ref: ref); - if (entity == null) { - throw DraftNotFoundException(ref: ref); - } - - return entity.toModel(); + Future get({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }) { + return _database.localDocumentsV2Dao + .getDocument(type: type, ref: ref, refTo: refTo) + .then((value) => value?.toModel()); } @override - Future> findAllVersions({required DocumentRef ref}) { - return _database.draftsDao - .queryAll(ref: ref) + Future> getAll({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + bool latestOnly = false, + int limit = 100, + int offset = 0, + }) { + return _database.localDocumentsV2Dao + .getDocuments( + type: type, + ref: ref, + refTo: refTo, + latestOnly: latestOnly, + limit: limit, + offset: offset, + ) .then((value) => value.map((e) => e.toModel()).toList()); } - @override - Future getLatest({CatalystId? authorId}) { - return _database.draftsDao.queryLatest(authorId: authorId).then((value) => value?.toModel()); - } - @override Future getLatestOf({required DocumentRef ref}) async { - // TODO(damian-molinski): not implemented - return null; - } - - @override - Future> queryVersionsOfId({required String id}) async { - final documentEntities = await _database.draftsDao.queryVersionsOfId(id: id); - return documentEntities.map((e) => e.toModel()).toList(); + return _database.localDocumentsV2Dao.getLatestOf(ref); } @override @@ -67,10 +81,9 @@ final class DatabaseDraftsDataSource implements DraftDataSource { @override Future saveAll(Iterable data) async { - // TODO(damian-molinski): migrate to V2 - /*final entries = data.map((e) => e.toEntity()).toList(); + final entries = data.map((e) => e.toEntity()).toList(); - await _database.localDraftsV2Dao.saveAll(entries);*/ + await _database.localDocumentsV2Dao.saveAll(entries); } @override @@ -78,35 +91,90 @@ final class DatabaseDraftsDataSource implements DraftDataSource { required DraftRef ref, required DocumentDataContent content, }) async { - await _database.draftsDao.updateContent(ref: ref, content: content); + await _database.localDocumentsV2Dao.updateContent(ref: ref, content: content); } @override - Stream watch({required DocumentRef ref}) { - return _database.draftsDao.watch(ref: ref).map((entity) => entity?.toModel()); + Stream watch({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }) { + return _database.localDocumentsV2Dao + .watchDocument(type: type, ref: ref, refTo: refTo) + .distinct() + .map((value) => value?.toModel()); } @override Stream> watchAll({ - int? limit, DocumentType? type, - CatalystId? authorId, + DocumentRef? ref, + DocumentRef? refTo, + bool latestOnly = false, + int limit = 100, + int offset = 0, }) { - return _database.draftsDao - .watchAll( + return _database.localDocumentsV2Dao + .watchDocuments( + type: type, + ref: ref, + refTo: refTo, + latestOnly: latestOnly, limit: limit, + offset: offset, + ) + .distinct(listEquals) + .map((value) => value.map((e) => e.toModel()).toList()); + } + + @override + Stream watchCount({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }) { + return _database.localDocumentsV2Dao + .watchCount( type: type, - authorId: authorId, + ref: ref, + refTo: refTo, ) - .map((event) { - final list = List.from(event.map((e) => e.toModel())); - return list; - }); + .distinct(); + } +} + +extension on LocalDocumentDraftEntity { + DocumentData toModel() { + return DocumentData( + metadata: DocumentDataMetadata( + type: type, + selfRef: DraftRef(id: id, version: ver), + ref: refId.toRef(refVer), + template: templateId.toRef(templateVer), + reply: replyId.toRef(replyVer), + section: section, + categoryId: categoryId.toRef(categoryVer), + authors: authors.isEmpty ? null : authors.split(',').map(CatalystId.parse).toList(), + ), + content: content, + ); + } +} + +extension on String? { + SignedDocumentRef? toRef([String? ver]) { + final id = this; + if (id == null) { + return null; + } + + return SignedDocumentRef(id: id, version: ver); } } extension on DocumentData { - /*LocalDocumentDraftEntity toEntity() { + LocalDocumentDraftEntity toEntity() { return LocalDocumentDraftEntity( content: content, id: metadata.id, @@ -124,5 +192,5 @@ extension on DocumentData { authors: metadata.authors?.map((e) => e.toUri().toString()).join(',') ?? '', createdAt: metadata.version.dateTime, ); - }*/ + } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart index f15aecc55cda..d7d055a2cfe0 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart @@ -3,77 +3,58 @@ import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; /// Base interface to interact with locally document data. abstract interface class DocumentDataLocalSource implements DocumentDataSource { - Future deleteAll(); + Future count({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }); + + Future delete({ + List? notInType, + }); Future exists({required DocumentRef ref}); Future> filterExisting(List refs); - Future> findAllVersions({required DocumentRef ref}); - - Future getLatest({ - CatalystId? authorId, + @override + Future get({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, }); - Future> queryVersionsOfId({required String id}); + Future> getAll({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + bool latestOnly, + int limit, + int offset, + }); Future save({required DocumentData data}); Future saveAll(Iterable data); - Stream watch({required DocumentRef ref}); -} - -/// See [DatabaseDraftsDataSource]. -abstract interface class DraftDataSource implements DocumentDataLocalSource { - Future delete({ - required DraftRef ref, - }); - - Future update({ - required DraftRef ref, - required DocumentDataContent content, - }); - - Stream> watchAll({ - int? limit, + Stream watch({ DocumentType? type, - CatalystId? authorId, - }); -} - -/// See [DatabaseDocumentsDataSource]. -abstract interface class SignedDocumentDataSource implements DocumentDataLocalSource { - @override - Future deleteAll({ - List? notInType, - }); - - Future getRefCount({ - required DocumentRef ref, - required DocumentType type, - }); - - Future getRefToDocumentData({ - required DocumentRef refTo, - required DocumentType type, + DocumentRef? ref, + DocumentRef? refTo, }); Stream> watchAll({ - int? limit, - required bool unique, DocumentType? type, - CatalystId? authorId, + DocumentRef? ref, DocumentRef? refTo, + bool latestOnly, + int limit, + int offset, }); Stream watchCount({ - DocumentRef? refTo, DocumentType? type, - }); - - Stream watchRefToDocumentData({ - required DocumentRef refTo, - required DocumentType type, + DocumentRef? ref, + DocumentRef? refTo, }); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_remote_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_remote_source.dart index cd8db83d71c0..bf70557c15b5 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_remote_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_remote_source.dart @@ -18,11 +18,11 @@ final class CatGatewayDocumentDataSource implements DocumentDataRemoteSource { ); @override - Future get({required DocumentRef ref}) async { + Future get({DocumentRef? ref}) async { final bytes = await _api.gateway .apiV1DocumentDocumentIdGet( - documentId: ref.id, - version: ref.version, + documentId: ref?.id, + version: ref?.version, ) .successBodyBytesOrThrow(); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart index 2135bb86bdf8..7a1a61c46489 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart @@ -1,8 +1,7 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -//ignore: one_member_abstracts abstract interface class DocumentDataSource { - Future get({required DocumentRef ref}); + Future get({DocumentRef? ref}); Future getLatestOf({required DocumentRef ref}); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart new file mode 100644 index 000000000000..5b3614e8a04e --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart @@ -0,0 +1,16 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; + +/// See [DatabaseDraftsDataSource]. +abstract interface class DraftDataSource implements DocumentDataLocalSource { + @override + Future delete({ + DocumentRef? ref, + List? notInType, + }); + + Future update({ + required DraftRef ref, + required DocumentDataContent content, + }); +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart new file mode 100644 index 000000000000..50fd4f8fdcf5 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart @@ -0,0 +1,24 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; + +/// See [DatabaseDocumentsDataSource]. +abstract interface class SignedDocumentDataSource implements DocumentDataLocalSource { + @override + Future get({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + CatalystId? authorId, + }); + + @override + Stream> watchAll({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + CatalystId? authorId, + bool latestOnly, + int limit, + int offset, + }); +} 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 c4c5b3fe56dc..b1f697b21303 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 @@ -130,7 +130,7 @@ final class ProposalRepositoryImpl implements ProposalRepository { }) async { final documentData = await _documentRepository.getDocumentData(ref: ref); final commentsCount = await _documentRepository.getRefCount( - ref: ref, + refTo: ref, type: DocumentType.commentDocument, ); final proposalPublish = await getProposalPublishForRef(ref: ref); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart index 5ff3b5876707..43de6b877004 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart @@ -1,11 +1,14 @@ // ignore_for_file: avoid_redundant_argument_values +import 'dart:typed_data'; + import 'package:catalyst_voices_dev/catalyst_voices_dev.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; import 'package:catalyst_voices_repositories/src/database/dao/documents_v2_dao.dart'; import 'package:catalyst_voices_repositories/src/database/model/document_with_authors_entity.dart'; import 'package:catalyst_voices_repositories/src/database/table/documents_v2.drift.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../utils/document_with_authors_factory.dart'; @@ -69,6 +72,275 @@ void main() { // Then expect(result, 2); }); + + test('filters by type and returns matching count', () async { + // Given + final proposal = _createTestDocumentEntity( + id: 'proposal-id', + ver: 'proposal-ver', + type: DocumentType.proposalDocument, + ); + final comment = _createTestDocumentEntity( + id: 'comment-id', + ver: 'comment-ver', + type: DocumentType.commentDocument, + ); + final template = _createTestDocumentEntity( + id: 'template-id', + ver: 'template-ver', + type: DocumentType.proposalTemplate, + ); + await dao.saveAll([proposal, comment, template]); + + // When + final result = await dao.count(type: DocumentType.proposalDocument); + + // Then + expect(result, 1); + }); + + test('returns zero when no documents match type filter', () async { + // Given + final comment = _createTestDocumentEntity( + id: 'comment-id', + ver: 'comment-ver', + type: DocumentType.commentDocument, + ); + await dao.save(comment); + + // When + final result = await dao.count(type: DocumentType.proposalDocument); + + // Then + expect(result, 0); + }); + + test('filters by loose ref and returns all versions count', () async { + // Given + final v1 = _createTestDocumentEntity(id: 'multi-id', ver: 'ver-1'); + final v2 = _createTestDocumentEntity(id: 'multi-id', ver: 'ver-2'); + final other = _createTestDocumentEntity(id: 'other-id', ver: 'other-ver'); + await dao.saveAll([v1, v2, other]); + + // When + final result = await dao.count( + ref: const SignedDocumentRef.loose(id: 'multi-id'), + ); + + // Then + expect(result, 2); + }); + + test('filters by exact ref and returns single match', () async { + // Given + final v1 = _createTestDocumentEntity(id: 'multi-id', ver: 'ver-1'); + final v2 = _createTestDocumentEntity(id: 'multi-id', ver: 'ver-2'); + await dao.saveAll([v1, v2]); + + // When + final result = await dao.count( + ref: const SignedDocumentRef.exact(id: 'multi-id', version: 'ver-1'), + ); + + // Then + expect(result, 1); + }); + + test('returns zero for non-existing ref', () async { + // Given + final entity = _createTestDocumentEntity(id: 'existing-id', ver: 'existing-ver'); + await dao.save(entity); + + // When + final result = await dao.count( + ref: const SignedDocumentRef.exact(id: 'non-existent', version: 'non-ver'), + ); + + // Then + expect(result, 0); + }); + + test('filters by loose refTo and returns documents referencing id', () async { + // Given + final proposal = _createTestDocumentEntity( + id: 'proposal-id', + ver: 'proposal-ver', + type: DocumentType.proposalDocument, + ); + final action1 = _createTestDocumentEntity( + id: 'action-1', + ver: 'action-ver-1', + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver', + ); + final action2 = _createTestDocumentEntity( + id: 'action-2', + ver: 'action-ver-2', + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver-2', + ); + final unrelated = _createTestDocumentEntity( + id: 'unrelated', + ver: 'unrelated-ver', + type: DocumentType.proposalActionDocument, + refId: 'other-proposal', + refVer: 'other-ver', + ); + await dao.saveAll([proposal, action1, action2, unrelated]); + + // When + final result = await dao.count( + refTo: const SignedDocumentRef.loose(id: 'proposal-id'), + ); + + // Then + expect(result, 2); + }); + + test('filters by exact refTo and returns documents referencing exact version', () async { + // Given + final action1 = _createTestDocumentEntity( + id: 'action-1', + ver: 'action-ver-1', + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver-1', + ); + final action2 = _createTestDocumentEntity( + id: 'action-2', + ver: 'action-ver-2', + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver-2', + ); + await dao.saveAll([action1, action2]); + + // When + final result = await dao.count( + refTo: const SignedDocumentRef.exact( + id: 'proposal-id', + version: 'proposal-ver-1', + ), + ); + + // Then + expect(result, 1); + }); + + test('returns zero when no documents match refTo filter', () async { + // Given + final action = _createTestDocumentEntity( + id: 'action-id', + ver: 'action-ver', + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver', + ); + await dao.save(action); + + // When + final result = await dao.count( + refTo: const SignedDocumentRef.loose(id: 'non-existent-proposal'), + ); + + // Then + expect(result, 0); + }); + + test('combines type and ref filters', () async { + // Given + final proposal1 = _createTestDocumentEntity( + id: 'proposal-id', + ver: 'ver-1', + type: DocumentType.proposalDocument, + ); + final proposal2 = _createTestDocumentEntity( + id: 'proposal-id', + ver: 'ver-2', + type: DocumentType.proposalDocument, + ); + final comment = _createTestDocumentEntity( + id: 'proposal-id', + ver: 'ver-3', + type: DocumentType.commentDocument, + ); + await dao.saveAll([proposal1, proposal2, comment]); + + // When + final result = await dao.count( + type: DocumentType.proposalDocument, + ref: const SignedDocumentRef.loose(id: 'proposal-id'), + ); + + // Then + expect(result, 2); + }); + + test('combines type and refTo filters', () async { + // Given + final action = _createTestDocumentEntity( + id: 'action-id', + ver: 'action-ver', + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver', + ); + final comment = _createTestDocumentEntity( + id: 'comment-id', + ver: 'comment-ver', + type: DocumentType.commentDocument, + refId: 'proposal-id', + refVer: 'proposal-ver', + ); + await dao.saveAll([action, comment]); + + // When + final result = await dao.count( + type: DocumentType.proposalActionDocument, + refTo: const SignedDocumentRef.loose(id: 'proposal-id'), + ); + + // Then + expect(result, 1); + }); + + test('combines all three filters', () async { + // Given + final action1 = _createTestDocumentEntity( + id: 'action-id', + ver: 'ver-1', + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver', + ); + final action2 = _createTestDocumentEntity( + id: 'action-id', + ver: 'ver-2', + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver', + ); + final action3 = _createTestDocumentEntity( + id: 'other-action', + ver: 'ver-3', + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver', + ); + await dao.saveAll([action1, action2, action3]); + + // When + final result = await dao.count( + type: DocumentType.proposalActionDocument, + ref: const SignedDocumentRef.loose(id: 'action-id'), + refTo: const SignedDocumentRef.loose(id: 'proposal-id'), + ); + + // Then + expect(result, 2); + }); }); group('exists', () { @@ -310,66 +582,425 @@ void main() { const ref = SignedDocumentRef.exact(id: 'test-id', version: 'test-ver'); // When - final result = await dao.getDocument(ref: ref); + final result = await dao.getDocument(ref: ref); + + // Then + expect(result, isNotNull); + expect(result!.id, 'test-id'); + expect(result.ver, 'test-ver'); + }); + + test('returns null for non-existing exact ref', () async { + // Given + final entity = _createTestDocumentEntity(id: 'test-id', ver: 'test-ver'); + await dao.save(entity); + + // And + const ref = SignedDocumentRef.exact(id: 'test-id', version: 'wrong-ver'); + + // When + final result = await dao.getDocument(ref: ref); + + // Then + expect(result, isNull); + }); + + test('returns latest entity for loose ref if versions exist', () async { + // Given + final oldCreatedAt = DateTime.utc(2023, 2, 2); + final newerCreatedAt = DateTime.utc(2024, 2, 2); + + final oldVer = _buildUuidV7At(oldCreatedAt); + final newerVer = _buildUuidV7At(newerCreatedAt); + final entityOld = _createTestDocumentEntity(id: 'test-id', ver: oldVer); + final entityNew = _createTestDocumentEntity(id: 'test-id', ver: newerVer); + await dao.saveAll([entityOld, entityNew]); + + // And + const ref = SignedDocumentRef.loose(id: 'test-id'); + + // When + final result = await dao.getDocument(ref: ref); + + // Then + expect(result, isNotNull); + expect(result!.ver, newerVer); + expect(result.createdAt, newerCreatedAt); + }); + + test('returns null for loose ref if no versions exist', () async { + // Given + final entity = _createTestDocumentEntity(id: 'other-id', ver: 'other-ver'); + await dao.save(entity); + + // And + const ref = SignedDocumentRef.loose(id: 'non-existent-id'); + + // When + final result = await dao.getDocument(ref: ref); + + // Then + expect(result, isNull); + }); + + test('filters by type and returns matching document', () async { + // Given + final proposal = _createTestDocumentEntity( + id: 'proposal-id', + ver: 'proposal-ver', + type: DocumentType.proposalDocument, + ); + final comment = _createTestDocumentEntity( + id: 'comment-id', + ver: 'comment-ver', + type: DocumentType.commentDocument, + ); + await dao.saveAll([proposal, comment]); + + // When + final result = await dao.getDocument(type: DocumentType.proposalDocument); + + // Then + expect(result, isNotNull); + expect(result!.id, 'proposal-id'); + expect(result.type, DocumentType.proposalDocument); + }); + + test('returns null when no documents match type filter', () async { + // Given + final comment = _createTestDocumentEntity( + id: 'comment-id', + ver: 'comment-ver', + type: DocumentType.commentDocument, + ); + await dao.save(comment); + + // When + final result = await dao.getDocument(type: DocumentType.proposalDocument); + + // Then + expect(result, isNull); + }); + + test('returns latest document when filtering by type only', () async { + // Given + final oldCreatedAt = DateTime.utc(2023, 1, 1); + final newerCreatedAt = DateTime.utc(2024, 1, 1); + + final oldVer = _buildUuidV7At(oldCreatedAt); + final newerVer = _buildUuidV7At(newerCreatedAt); + + final oldProposal = _createTestDocumentEntity( + id: 'proposal-1', + ver: oldVer, + type: DocumentType.proposalDocument, + ); + final newProposal = _createTestDocumentEntity( + id: 'proposal-2', + ver: newerVer, + type: DocumentType.proposalDocument, + ); + await dao.saveAll([oldProposal, newProposal]); + + // When + final result = await dao.getDocument(type: DocumentType.proposalDocument); + + // Then + expect(result, isNotNull); + }); + + test('filters by loose refTo and returns latest referencing document', () async { + // Given + final oldCreatedAt = DateTime.utc(2023, 1, 1); + final newerCreatedAt = DateTime.utc(2024, 1, 1); + + final oldVer = _buildUuidV7At(oldCreatedAt); + final newerVer = _buildUuidV7At(newerCreatedAt); + + final action1 = _createTestDocumentEntity( + id: 'action-1', + ver: oldVer, + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver', + ); + final action2 = _createTestDocumentEntity( + id: 'action-2', + ver: newerVer, + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver-2', + ); + final unrelated = _createTestDocumentEntity( + id: 'unrelated', + ver: 'unrelated-ver', + type: DocumentType.proposalActionDocument, + refId: 'other-proposal', + refVer: 'other-ver', + ); + await dao.saveAll([action1, action2, unrelated]); + + // When + final result = await dao.getDocument( + refTo: const SignedDocumentRef.loose(id: 'proposal-id'), + ); + + // Then + expect(result, isNotNull); + expect(result!.refId, 'proposal-id'); + }); + + test('filters by exact refTo and returns matching document', () async { + // Given + final action1 = _createTestDocumentEntity( + id: 'action-1', + ver: 'action-ver-1', + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver-1', + ); + final action2 = _createTestDocumentEntity( + id: 'action-2', + ver: 'action-ver-2', + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver-2', + ); + await dao.saveAll([action1, action2]); + + // When + final result = await dao.getDocument( + refTo: const SignedDocumentRef.exact( + id: 'proposal-id', + version: 'proposal-ver-1', + ), + ); + + // Then + expect(result, isNotNull); + expect(result!.id, 'action-1'); + expect(result.refVer, 'proposal-ver-1'); + }); + + test('returns null when no documents match refTo filter', () async { + // Given + final action = _createTestDocumentEntity( + id: 'action-id', + ver: 'action-ver', + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver', + ); + await dao.save(action); + + // When + final result = await dao.getDocument( + refTo: const SignedDocumentRef.loose(id: 'non-existent-proposal'), + ); + + // Then + expect(result, isNull); + }); + + test('combines type and ref filters', () async { + // Given + final proposal = _createTestDocumentEntity( + id: 'doc-id', + ver: 'ver-1', + type: DocumentType.proposalDocument, + ); + final comment = _createTestDocumentEntity( + id: 'doc-id', + ver: 'ver-2', + type: DocumentType.commentDocument, + ); + await dao.saveAll([proposal, comment]); + + // When + final result = await dao.getDocument( + type: DocumentType.proposalDocument, + ref: const SignedDocumentRef.loose(id: 'doc-id'), + ); + + // Then + expect(result, isNotNull); + expect(result!.id, 'doc-id'); + expect(result.ver, 'ver-1'); + expect(result.type, DocumentType.proposalDocument); + }); + + test('returns null when type and ref filters have no intersection', () async { + // Given + final proposal = _createTestDocumentEntity( + id: 'proposal-id', + ver: 'proposal-ver', + type: DocumentType.proposalDocument, + ); + await dao.save(proposal); + + // When + final result = await dao.getDocument( + type: DocumentType.commentDocument, + ref: const SignedDocumentRef.loose(id: 'proposal-id'), + ); + + // Then + expect(result, isNull); + }); + + test('combines type and refTo filters', () async { + // Given + final action = _createTestDocumentEntity( + id: 'action-id', + ver: 'action-ver', + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver', + ); + final comment = _createTestDocumentEntity( + id: 'comment-id', + ver: 'comment-ver', + type: DocumentType.commentDocument, + refId: 'proposal-id', + refVer: 'proposal-ver', + ); + await dao.saveAll([action, comment]); + + // When + final result = await dao.getDocument( + type: DocumentType.proposalActionDocument, + refTo: const SignedDocumentRef.loose(id: 'proposal-id'), + ); // Then expect(result, isNotNull); - expect(result!.id, 'test-id'); - expect(result.ver, 'test-ver'); + expect(result!.id, 'action-id'); + expect(result.type, DocumentType.proposalActionDocument); }); - test('returns null for non-existing exact ref', () async { + test('combines ref and refTo filters', () async { // Given - final entity = _createTestDocumentEntity(id: 'test-id', ver: 'test-ver'); - await dao.save(entity); - - // And - const ref = SignedDocumentRef.exact(id: 'test-id', version: 'wrong-ver'); + final action1 = _createTestDocumentEntity( + id: 'action-id', + ver: 'ver-1', + type: DocumentType.proposalActionDocument, + refId: 'proposal-1', + refVer: 'proposal-ver', + ); + final action2 = _createTestDocumentEntity( + id: 'action-id', + ver: 'ver-2', + type: DocumentType.proposalActionDocument, + refId: 'proposal-2', + refVer: 'proposal-ver', + ); + await dao.saveAll([action1, action2]); - // When: getDocument is called - final result = await dao.getDocument(ref: ref); + // When + final result = await dao.getDocument( + ref: const SignedDocumentRef.loose(id: 'action-id'), + refTo: const SignedDocumentRef.loose(id: 'proposal-1'), + ); - // Then: Returns null - expect(result, isNull); + // Then + expect(result, isNotNull); + expect(result!.id, 'action-id'); + expect(result.refId, 'proposal-1'); }); - test('returns latest entity for loose ref if versions exist', () async { + test('combines all three filters', () async { // Given - final oldCreatedAt = DateTime.utc(2023, 2, 2); - final newerCreatedAt = DateTime.utc(2024, 2, 2); + final oldCreatedAt = DateTime.utc(2023, 1, 1); + final newerCreatedAt = DateTime.utc(2024, 1, 1); final oldVer = _buildUuidV7At(oldCreatedAt); final newerVer = _buildUuidV7At(newerCreatedAt); - final entityOld = _createTestDocumentEntity(id: 'test-id', ver: oldVer); - final entityNew = _createTestDocumentEntity(id: 'test-id', ver: newerVer); - await dao.saveAll([entityOld, entityNew]); - // And - const ref = SignedDocumentRef.loose(id: 'test-id'); + final action1 = _createTestDocumentEntity( + id: 'action-id', + ver: oldVer, + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver', + ); + final action2 = _createTestDocumentEntity( + id: 'action-id', + ver: newerVer, + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver', + ); + final comment = _createTestDocumentEntity( + id: 'action-id', + ver: 'comment-ver', + type: DocumentType.commentDocument, + refId: 'proposal-id', + refVer: 'proposal-ver', + ); + await dao.saveAll([action1, action2, comment]); // When - final result = await dao.getDocument(ref: ref); + final result = await dao.getDocument( + type: DocumentType.proposalActionDocument, + ref: const SignedDocumentRef.loose(id: 'action-id'), + refTo: const SignedDocumentRef.loose(id: 'proposal-id'), + ); // Then expect(result, isNotNull); - expect(result!.ver, newerVer); - expect(result.createdAt, newerCreatedAt); + expect(result!.id, 'action-id'); + expect(result.ver, newerVer); + expect(result.type, DocumentType.proposalActionDocument); + expect(result.refId, 'proposal-id'); }); - test('returns null for loose ref if no versions exist', () async { + test('returns null when all three filters have no intersection', () async { // Given - final entity = _createTestDocumentEntity(id: 'other-id', ver: 'other-ver'); - await dao.save(entity); - - // And - const ref = SignedDocumentRef.loose(id: 'non-existent-id'); + final action = _createTestDocumentEntity( + id: 'action-id', + ver: 'action-ver', + type: DocumentType.proposalActionDocument, + refId: 'proposal-id', + refVer: 'proposal-ver', + ); + await dao.save(action); // When - final result = await dao.getDocument(ref: ref); + final result = await dao.getDocument( + type: DocumentType.commentDocument, + ref: const SignedDocumentRef.loose(id: 'action-id'), + refTo: const SignedDocumentRef.loose(id: 'proposal-id'), + ); // Then expect(result, isNull); }); + + test('returns newest document by author', () async { + // Given + final author = _createTestAuthor(name: 'Damian'); + final proposal1 = _createTestDocumentEntity( + id: 'proposal1-id', + ver: _buildUuidV7At(DateTime(2023)), + type: DocumentType.proposalDocument, + authors: author.toUri().toString(), + ); + final newerVer = _buildUuidV7At(DateTime(2024)); + final proposal2 = _createTestDocumentEntity( + id: 'proposal2-id', + ver: newerVer, + type: DocumentType.proposalDocument, + authors: author.toUri().toString(), + ); + await dao.saveAll([proposal1, proposal2]); + + // When + final result = await dao.getDocument(author: author); + + // Then + expect(result, isNotNull); + expect(result?.ver, newerVer); + }); }); group('saveAll', () { @@ -1284,11 +1915,389 @@ void main() { expect(await dao.count(), 500); }); }); + + group('getDocuments', () { + test('returns empty list for empty database', () async { + // Given: An empty database + + // When + final result = await dao.getDocuments( + latestOnly: false, + limit: 100, + offset: 0, + ); + + // Then + expect(result, isEmpty); + }); + + test('returns all documents when no filters applied', () async { + // Given + final doc1 = _createTestDocumentEntity(id: 'id-1', ver: 'ver-1'); + final doc2 = _createTestDocumentEntity(id: 'id-2', ver: 'ver-1'); + await dao.saveAll([doc1, doc2]); + + // When + final result = await dao.getDocuments( + latestOnly: false, + limit: 100, + offset: 0, + ); + + // Then + expect(result.length, 2); + expect(result.map((e) => e.id), containsAll(['id-1', 'id-2'])); + }); + + test('filters by type', () async { + // Given + final proposal = _createTestDocumentEntity( + id: 'p-1', + type: DocumentType.proposalDocument, + ); + final template = _createTestDocumentEntity( + id: 't-1', + type: DocumentType.proposalTemplate, + ); + await dao.saveAll([proposal, template]); + + // When + final result = await dao.getDocuments( + type: DocumentType.proposalDocument, + latestOnly: false, + limit: 100, + offset: 0, + ); + + // Then + expect(result.length, 1); + expect(result.first.id, 'p-1'); + expect(result.first.type, DocumentType.proposalDocument); + }); + + test('filters by loose ref (returns all versions)', () async { + // Given + final v1 = _createTestDocumentEntity(id: 'doc-1', ver: 'v1'); + final v2 = _createTestDocumentEntity(id: 'doc-1', ver: 'v2'); + final other = _createTestDocumentEntity(id: 'doc-2', ver: 'v1'); + await dao.saveAll([v1, v2, other]); + + // When + final result = await dao.getDocuments( + ref: const SignedDocumentRef.loose(id: 'doc-1'), + latestOnly: false, + limit: 100, + offset: 0, + ); + + // Then + expect(result.length, 2); + expect(result.map((e) => e.ver), containsAll(['v1', 'v2'])); + }); + + test('filters by exact ref', () async { + // Given + final v1 = _createTestDocumentEntity(id: 'doc-1', ver: 'v1'); + final v2 = _createTestDocumentEntity(id: 'doc-1', ver: 'v2'); + await dao.saveAll([v1, v2]); + + // When + final result = await dao.getDocuments( + ref: const SignedDocumentRef.exact(id: 'doc-1', version: 'v1'), + latestOnly: false, + limit: 100, + offset: 0, + ); + + // Then + expect(result.length, 1); + expect(result.first.ver, 'v1'); + }); + + test('filters by refTo (loose)', () async { + // Given + final target = _createTestDocumentEntity(id: 'target-1'); + final ref1 = _createTestDocumentEntity( + id: 'ref-1', + refId: 'target-1', + refVer: 'any', + ); + final ref2 = _createTestDocumentEntity( + id: 'ref-2', + refId: 'target-1', + refVer: 'other', + ); + final other = _createTestDocumentEntity(id: 'other', refId: 'other-target'); + await dao.saveAll([target, ref1, ref2, other]); + + // When + final result = await dao.getDocuments( + refTo: const SignedDocumentRef.loose(id: 'target-1'), + latestOnly: false, + limit: 100, + offset: 0, + ); + + // Then + expect(result.length, 2); + expect(result.map((e) => e.id), containsAll(['ref-1', 'ref-2'])); + }); + + test('filters by refTo (exact)', () async { + // Given + final v1 = _createTestDocumentEntity( + id: 'ref-1', + refId: 'target', + refVer: 'v1', + ); + final v2 = _createTestDocumentEntity( + id: 'ref-2', + refId: 'target', + refVer: 'v2', + ); + await dao.saveAll([v1, v2]); + + // When + final result = await dao.getDocuments( + refTo: const SignedDocumentRef.exact(id: 'target', version: 'v1'), + latestOnly: false, + limit: 100, + offset: 0, + ); + + // Then + expect(result.length, 1); + expect(result.first.id, 'ref-1'); + }); + + test('returns only latest versions when latestOnly is true', () async { + // Given + final oldTime = DateTime.utc(2024, 1, 1); + final newTime = DateTime.utc(2024, 1, 2); + + final doc1V1 = _createTestDocumentEntity( + id: 'doc-1', + ver: _buildUuidV7At(oldTime), + ); + final doc1V2 = _createTestDocumentEntity( + id: 'doc-1', + ver: _buildUuidV7At(newTime), + ); + final doc2 = _createTestDocumentEntity( + id: 'doc-2', + ver: _buildUuidV7At(oldTime), + ); + + await dao.saveAll([doc1V1, doc1V2, doc2]); + + // When + final result = await dao.getDocuments( + latestOnly: true, + limit: 100, + offset: 0, + ); + + // Then + expect(result.length, 2); + expect(result.map((e) => e.id), containsAll(['doc-1', 'doc-2'])); + + final resultDoc1 = result.firstWhere((e) => e.id == 'doc-1'); + expect(resultDoc1.ver, doc1V2.doc.ver); + }); + + test('pagination works with limit and offset', () async { + // Given: 5 documents + final docs = List.generate( + 5, + (i) => _createTestDocumentEntity( + id: 'doc-$i', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 1).add(Duration(minutes: i))), + ), + ); + + await dao.saveAll(docs); + + // When + final result = await dao.getDocuments( + latestOnly: false, + limit: 2, + offset: 1, + ); + + // Then + expect(result.length, 2); + }); + + test('respects campaign filters (categories)', () async { + // Given + final doc1 = _createTestDocumentEntity(id: 'd1', categoryId: 'cat-1'); + final doc2 = _createTestDocumentEntity(id: 'd2', categoryId: 'cat-2'); + final doc3 = _createTestDocumentEntity(id: 'd3', categoryId: 'cat-1'); + await dao.saveAll([doc1, doc2, doc3]); + + // When + final result = await dao.getDocuments( + filters: const CampaignFilters(categoriesIds: ['cat-1']), + latestOnly: false, + limit: 100, + offset: 0, + ); + + // Then + expect(result.length, 2); + expect(result.map((e) => e.id), containsAll(['d1', 'd3'])); + }); + + test('combines type, latestOnly and campaign filters', () async { + // Given + final oldProposal = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(DateTime(2023)), + type: DocumentType.proposalDocument, + categoryId: 'cat-A', + ); + final newProposal = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(DateTime(2024)), + type: DocumentType.proposalDocument, + categoryId: 'cat-A', + ); + final otherCatProposal = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(DateTime(2024)), + type: DocumentType.proposalDocument, + categoryId: 'cat-B', + ); + final wrongType = _createTestDocumentEntity( + id: 't1', + type: DocumentType.proposalTemplate, + categoryId: 'cat-A', + ); + + await dao.saveAll([oldProposal, newProposal, otherCatProposal, wrongType]); + + // When + final result = await dao.getDocuments( + type: DocumentType.proposalDocument, + filters: const CampaignFilters(categoriesIds: ['cat-A']), + latestOnly: true, + limit: 10, + offset: 0, + ); + + // Then + expect(result.length, 1); + expect(result.first.id, 'p1'); + expect(result.first.ver, newProposal.doc.ver); + }); + + test('results should be ordered by createdAt DESC (Newest First)', () async { + // Given: Three documents with distinct creation times + final oldestDate = DateTime.utc(2023, 1, 1); + final middleDate = DateTime.utc(2023, 6, 1); + final newestDate = DateTime.utc(2024, 1, 1); + + final oldestDoc = _createTestDocumentEntity( + id: 'doc-old', + ver: _buildUuidV7At(oldestDate), + ); + final middleDoc = _createTestDocumentEntity( + id: 'doc-mid', + ver: _buildUuidV7At(middleDate), + ); + final newestDoc = _createTestDocumentEntity( + id: 'doc-new', + ver: _buildUuidV7At(newestDate), + ); + + // When: Saved in SCRAMBLED order (Old -> New -> Middle) + await dao.save(oldestDoc); + await dao.save(newestDoc); + await dao.save(middleDoc); + + // And: We query with pagination + final result = await dao.getDocuments( + latestOnly: false, + limit: 10, + offset: 0, + ); + + // Then: We EXPECT them sorted by time (New -> Mid -> Old) + expect(result.length, 3); + + expect( + result[0].id, + 'doc-new', + reason: 'First item should be the newest document', + ); + + expect( + result[1].id, + 'doc-mid', + reason: 'Second item should be the middle document', + ); + + expect( + result[2].id, + 'doc-old', + reason: 'Last item should be the oldest document', + ); + }); + + test('pagination stays consistent across updates', () async { + // Given + final docs = List.generate( + 5, + (i) => _createTestDocumentEntity( + id: 'doc-$i', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 1).add(Duration(minutes: i))), + ), + ); + + await dao.saveAll(docs); + + // When: We fetch Page 1 (size 2) + final page1 = await dao.getDocuments(latestOnly: false, limit: 2, offset: 0); + + // And: We insert a VERY OLD document (simulating a sync of historical data) + final ancientDoc = _createTestDocumentEntity( + id: 'doc-ancient', + ver: _buildUuidV7At(DateTime.utc(2020, 1, 1)), + ); + await dao.save(ancientDoc); + + // And + final page1Again = await dao.getDocuments(latestOnly: false, limit: 2, offset: 0); + + // Then + expect(page1Again[0].id, page1[0].id); + }); + }); }); } String _buildUuidV7At(DateTime dateTime) => DocumentRefFactory.uuidV7At(dateTime); +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)); + + return CatalystId.parse(buffer.toString()); +} + DocumentWithAuthorsEntity _createTestDocumentEntity({ String? id, String? ver, 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 c2084e569219..340336463345 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 @@ -403,8 +403,8 @@ final class ProposalServiceImpl implements ProposalService { @override Stream watchMaxProposalsLimitReached() { - // TODO(damian-molinski): watch active account id + active campain - const filters = const ProposalsFiltersV2( + // TODO(damian-molinski): watch active account id + active campaign + const filters = ProposalsFiltersV2( status: ProposalStatusFilter.aFinal, author: null, campaign: null, diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/proposal/proposal_service_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/proposal/proposal_service_test.dart index 6ae4e0d320a0..b5ab9f18f979 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/proposal/proposal_service_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/proposal/proposal_service_test.dart @@ -39,6 +39,7 @@ void main() { ); registerFallbackValue(const SignedDocumentRef(id: 'fallback-id')); + registerFallbackValue(const ProposalsFiltersV2()); when( () => mockDocumentRepository.watchCount( From 5819aa490dcc4625d0515359db8dd2db82f09c3e Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Wed, 19 Nov 2025 10:55:14 +0100 Subject: [PATCH 21/30] categoryId -> categoryRef --- .../apps/voices/lib/pages/category/category_page.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/catalyst_voices/apps/voices/lib/pages/category/category_page.dart b/catalyst_voices/apps/voices/lib/pages/category/category_page.dart index f7783fa3f0d1..7dcf5535f469 100644 --- a/catalyst_voices/apps/voices/lib/pages/category/category_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/category/category_page.dart @@ -141,9 +141,9 @@ class _CategoryDetailContent extends StatelessWidget { } class _CategoryDetailError extends StatelessWidget { - final SignedDocumentRef categoryId; + final SignedDocumentRef categoryRef; - const _CategoryDetailError({required this.categoryId}); + const _CategoryDetailError({required this.categoryRef}); @override Widget build(BuildContext context) { @@ -167,7 +167,7 @@ class _CategoryDetailError extends StatelessWidget { ? null : () { unawaited( - context.read().getCategoryDetail(categoryId), + context.read().getCategoryDetail(categoryRef), ); }, ), @@ -189,7 +189,7 @@ class _CategoryPageState extends State { children: [ const _CategoryDetailContent(), _CategoryDetailError( - categoryId: widget.categoryRef, + categoryRef: widget.categoryRef, ), ].constrainedDelegate(), ), From 3d892f0b6b10e33fd1ba24f1fd737daa24d9f639 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Fri, 21 Nov 2025 11:44:02 +0100 Subject: [PATCH 22/30] chore: notInType -> typeNotIn --- .../lib/src/database/dao/documents_v2_dao.dart | 12 ++++++------ .../database/dao/local_draft_documents_v2_dao.dart | 8 ++++---- .../lib/src/document/document_repository.dart | 4 ++-- .../source/database_documents_data_source.dart | 4 ++-- .../source/database_drafts_data_source.dart | 4 ++-- .../source/document_data_local_source.dart | 2 +- .../source/local_document_data_local_source.dart | 2 +- .../src/database/dao/documents_v2_dao_test.dart | 14 +++++++------- 8 files changed, 25 insertions(+), 25 deletions(-) diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart index 115b9b85af7e..f11a60db5e86 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart @@ -23,15 +23,15 @@ abstract interface class DocumentsV2Dao { DocumentRef? refTo, }); - /// Deletes documents from the database, preserving those with types in [notInType]. + /// Deletes documents from the database, preserving those with types in [typeNotIn]. /// - /// If [notInType] is null or empty, this may delete *all* documents (implementation dependent). + /// If [typeNotIn] is null or empty, this may delete *all* documents (implementation dependent). /// Typically used for cache invalidation or cleaning up old data while keeping /// certain important types (e.g. keeping local drafts or templates). /// /// Returns the number of deleted rows. Future deleteWhere({ - List? notInType, + List? typeNotIn, }); /// Checks if a document exists in the database. @@ -160,12 +160,12 @@ class DriftDocumentsV2Dao extends DatabaseAccessor @override Future deleteWhere({ - List? notInType, + List? typeNotIn, }) { final query = delete(documentsV2); - if (notInType != null) { - query.where((tbl) => tbl.type.isNotInValues(notInType)); + if (typeNotIn != null) { + query.where((tbl) => tbl.type.isNotInValues(typeNotIn)); } return query.go(); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/local_draft_documents_v2_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/local_draft_documents_v2_dao.dart index 3b7e615ebb81..0c512e5d5c64 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/local_draft_documents_v2_dao.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/local_draft_documents_v2_dao.dart @@ -28,12 +28,12 @@ class DriftLocalDraftDocumentsV2Dao extends DatabaseAccessor deleteWhere({ DocumentRef? ref, - List? notInType, + List? typeNotIn, }) { final query = delete(localDocumentsDrafts); - if (notInType != null) { - query.where((tbl) => tbl.type.isNotInValues(notInType)); + if (typeNotIn != null) { + query.where((tbl) => tbl.type.isNotInValues(typeNotIn)); } return query.go(); @@ -338,7 +338,7 @@ abstract interface class LocalDraftDocumentsV2Dao { Future deleteWhere({ DocumentRef? ref, - List? notInType, + List? typeNotIn, }); Future exists(DocumentRef ref); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart index bb6f8539ea94..96229a0f03c4 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart @@ -410,8 +410,8 @@ final class DocumentRepositoryImpl implements DocumentRepository { bool keepLocalDrafts = false, }) async { final deletedDrafts = keepLocalDrafts ? 0 : await _drafts.delete(); - final notInType = keepLocalDrafts ? [DocumentType.proposalTemplate] : null; - final deletedDocuments = await _localDocuments.delete(notInType: notInType); + final typeNotIn = keepLocalDrafts ? [DocumentType.proposalTemplate] : null; + final deletedDocuments = await _localDocuments.delete(typeNotIn: typeNotIn); return deletedDrafts + deletedDocuments; } 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 881ac5e99a85..60a2f2c975ba 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 @@ -34,9 +34,9 @@ final class DatabaseDocumentsDataSource @override Future delete({ - List? notInType, + List? typeNotIn, }) { - return _database.documentsV2Dao.deleteWhere(notInType: notInType); + return _database.documentsV2Dao.deleteWhere(typeNotIn: typeNotIn); } @override diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart index 308b8abc5a13..1a7d94018ca1 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart @@ -24,9 +24,9 @@ final class DatabaseDraftsDataSource implements DraftDataSource { @override Future delete({ DocumentRef? ref, - List? notInType, + List? typeNotIn, }) { - return _database.localDocumentsV2Dao.deleteWhere(ref: ref, notInType: notInType); + return _database.localDocumentsV2Dao.deleteWhere(ref: ref, typeNotIn: typeNotIn); } @override diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart index d7d055a2cfe0..9d536ad0085e 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart @@ -10,7 +10,7 @@ abstract interface class DocumentDataLocalSource implements DocumentDataSource { }); Future delete({ - List? notInType, + List? typeNotIn, }); Future exists({required DocumentRef ref}); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart index 5b3614e8a04e..4e7e65df01a8 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart @@ -6,7 +6,7 @@ abstract interface class DraftDataSource implements DocumentDataLocalSource { @override Future delete({ DocumentRef? ref, - List? notInType, + List? typeNotIn, }); Future update({ diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart index 53325df9577e..7e9f812d37c5 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart @@ -1699,7 +1699,7 @@ void main() { // When final result = await dao.deleteWhere( - notInType: [DocumentType.proposalDocument], + typeNotIn: [DocumentType.proposalDocument], ); // Then @@ -1739,7 +1739,7 @@ void main() { // When final result = await dao.deleteWhere( - notInType: [ + typeNotIn: [ DocumentType.proposalDocument, DocumentType.proposalTemplate, ], @@ -1785,7 +1785,7 @@ void main() { await dao.saveAll(entities); // When - final result = await dao.deleteWhere(notInType: []); + final result = await dao.deleteWhere(typeNotIn: []); // Then expect(result, 2); @@ -1810,7 +1810,7 @@ void main() { // When final result = await dao.deleteWhere( - notInType: [DocumentType.proposalDocument], + typeNotIn: [DocumentType.proposalDocument], ); // Then @@ -1839,7 +1839,7 @@ void main() { // When final result = await dao.deleteWhere( - notInType: [DocumentType.proposalDocument], + typeNotIn: [DocumentType.proposalDocument], ); // Then @@ -1880,7 +1880,7 @@ void main() { // When final result = await dao.deleteWhere( - notInType: [ + typeNotIn: [ DocumentType.proposalDocument, DocumentType.proposalTemplate, DocumentType.proposalActionDocument, @@ -1906,7 +1906,7 @@ void main() { // When final result = await dao.deleteWhere( - notInType: [DocumentType.proposalDocument], + typeNotIn: [DocumentType.proposalDocument], ); // Then From 566acd67c539b58b5c99f43cc3ca316039c3f460 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Fri, 21 Nov 2025 11:45:37 +0100 Subject: [PATCH 23/30] chore: move DriftDocumentsV2LocalMetadataDao setup into group --- .../documents_v2_local_metadata_dao_test.dart | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_local_metadata_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_local_metadata_dao_test.dart index 9851af2b95c5..6511469fdb58 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_local_metadata_dao_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_local_metadata_dao_test.dart @@ -6,20 +6,20 @@ import 'package:flutter_test/flutter_test.dart'; import '../connection/test_connection.dart'; void main() { - late DriftCatalystDatabase db; - late DocumentsV2LocalMetadataDao dao; + group(DriftDocumentsV2LocalMetadataDao, () { + late DriftCatalystDatabase db; + late DocumentsV2LocalMetadataDao dao; - setUp(() async { - final connection = await buildTestConnection(); - db = DriftCatalystDatabase(connection); - dao = db.driftDocumentsV2LocalMetadataDao; - }); + setUp(() async { + final connection = await buildTestConnection(); + db = DriftCatalystDatabase(connection); + dao = db.driftDocumentsV2LocalMetadataDao; + }); - tearDown(() async { - await db.close(); - }); + tearDown(() async { + await db.close(); + }); - group(DriftDocumentsV2LocalMetadataDao, () { group('deleteWhere', () { test('returns zero when database is empty', () async { // Given: An empty database From a5e8acf60484298e300edaf15bc77a89fb058584 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Fri, 21 Nov 2025 12:05:59 +0100 Subject: [PATCH 24/30] separate get and getWhere --- .../lib/src/document/document_repository.dart | 16 +++++------ .../database_documents_data_source.dart | 25 +++++++++-------- .../source/database_drafts_data_source.dart | 23 ++++++++------- .../source/document_data_local_source.dart | 11 ++++---- .../source/document_data_remote_source.dart | 8 +++--- .../document/source/document_data_source.dart | 4 +-- .../signed_document_data_local_source.dart | 2 +- .../document/document_repository_test.dart | 28 +++++++++---------- 8 files changed, 61 insertions(+), 56 deletions(-) diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart index 96229a0f03c4..abc6371c5ebf 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart @@ -298,8 +298,8 @@ final class DocumentRepositoryImpl implements DocumentRepository { Future getLatestDocument({ CatalystId? authorId, }) async { - final latestDocument = await _localDocuments.get(authorId: authorId); - final latestDraft = await _drafts.get(); + final latestDocument = await _localDocuments.getWhere(authorId: authorId); + final latestDraft = await _drafts.getWhere(); return [latestDocument, latestDraft].nonNulls.sorted((a, b) => a.compareTo(b)).firstOrNull; } @@ -307,12 +307,12 @@ final class DocumentRepositoryImpl implements DocumentRepository { // TODO(damian-molinski): consider also checking with remote source. @override Future getLatestOf({required DocumentRef ref}) async { - final draft = await _drafts.getLatestOf(ref: ref); + final draft = await _drafts.getLatestRefOf(ref); if (draft != null) { return draft; } - return _localDocuments.getLatestOf(ref: ref); + return _localDocuments.getLatestRefOf(ref); } @override @@ -328,7 +328,7 @@ final class DocumentRepositoryImpl implements DocumentRepository { required DocumentRef refTo, required DocumentType type, }) { - return _localDocuments.get(refTo: refTo, type: type); + return _localDocuments.getWhere(refTo: refTo, type: type); } @override @@ -614,7 +614,7 @@ final class DocumentRepositoryImpl implements DocumentRepository { Future _getDraftDocumentData({ required DraftRef ref, }) async { - return _drafts.get(ref: ref); + return _drafts.get(ref); } Future _getSignedDocumentData({ @@ -631,10 +631,10 @@ final class DocumentRepositoryImpl implements DocumentRepository { final isCached = useCache && await _localDocuments.exists(ref: ref); if (isCached) { - return _localDocuments.get(ref: ref); + return _localDocuments.get(ref); } - final document = await _remoteDocuments.get(ref: ref); + final document = await _remoteDocuments.get(ref); if (useCache && document != null) { await _localDocuments.save(data: document); 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 60a2f2c975ba..e0971d59aaf5 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 @@ -50,16 +50,7 @@ final class DatabaseDocumentsDataSource } @override - Future get({ - DocumentType? type, - DocumentRef? ref, - DocumentRef? refTo, - CatalystId? authorId, - }) { - return _database.documentsV2Dao - .getDocument(type: type, ref: ref, refTo: refTo, author: authorId) - .then((value) => value?.toModel()); - } + Future get(DocumentRef ref) => getWhere(ref: ref); @override Future> getAll({ @@ -83,7 +74,7 @@ final class DatabaseDocumentsDataSource } @override - Future getLatestOf({required DocumentRef ref}) { + Future getLatestRefOf(DocumentRef ref) { return _database.documentsV2Dao.getLatestOf(ref); } @@ -95,6 +86,18 @@ final class DatabaseDocumentsDataSource return _database.proposalsV2Dao.getProposalsTotalTask(filters: filters, nodeId: nodeId); } + @override + Future getWhere({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + CatalystId? authorId, + }) { + return _database.documentsV2Dao + .getDocument(type: type, ref: ref, refTo: refTo, author: authorId) + .then((value) => value?.toModel()); + } + @override Future save({required DocumentData data}) => saveAll([data]); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart index 1a7d94018ca1..f51f301bec7a 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart @@ -40,15 +40,7 @@ final class DatabaseDraftsDataSource implements DraftDataSource { } @override - Future get({ - DocumentType? type, - DocumentRef? ref, - DocumentRef? refTo, - }) { - return _database.localDocumentsV2Dao - .getDocument(type: type, ref: ref, refTo: refTo) - .then((value) => value?.toModel()); - } + Future get(DocumentRef ref) => getWhere(ref: ref); @override Future> getAll({ @@ -72,10 +64,21 @@ final class DatabaseDraftsDataSource implements DraftDataSource { } @override - Future getLatestOf({required DocumentRef ref}) async { + Future getLatestRefOf(DocumentRef ref) async { return _database.localDocumentsV2Dao.getLatestOf(ref); } + @override + Future getWhere({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + }) { + return _database.localDocumentsV2Dao + .getDocument(type: type, ref: ref, refTo: refTo) + .then((value) => value?.toModel()); + } + @override Future save({required DocumentData data}) => saveAll([data]); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart index 9d536ad0085e..7ce471bf992e 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart @@ -17,20 +17,19 @@ abstract interface class DocumentDataLocalSource implements DocumentDataSource { Future> filterExisting(List refs); - @override - Future get({ + Future> getAll({ DocumentType? type, DocumentRef? ref, DocumentRef? refTo, + bool latestOnly, + int limit, + int offset, }); - Future> getAll({ + Future getWhere({ DocumentType? type, DocumentRef? ref, DocumentRef? refTo, - bool latestOnly, - int limit, - int offset, }); Future save({required DocumentData data}); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_remote_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_remote_source.dart index bf70557c15b5..fd43680f61e0 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_remote_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_remote_source.dart @@ -18,11 +18,11 @@ final class CatGatewayDocumentDataSource implements DocumentDataRemoteSource { ); @override - Future get({DocumentRef? ref}) async { + Future get(DocumentRef ref) async { final bytes = await _api.gateway .apiV1DocumentDocumentIdGet( - documentId: ref?.id, - version: ref?.version, + documentId: ref.id, + version: ref.version, ) .successBodyBytesOrThrow(); @@ -31,7 +31,7 @@ final class CatGatewayDocumentDataSource implements DocumentDataRemoteSource { } @override - Future getLatestOf({required DocumentRef ref}) async { + Future getLatestRefOf(DocumentRef ref) async { final ver = await getLatestVersion(ref.id); if (ver == null) { return null; diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart index 7a1a61c46489..bd9ff123a7b6 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart @@ -1,7 +1,7 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; abstract interface class DocumentDataSource { - Future get({DocumentRef? ref}); + Future get(DocumentRef ref); - Future getLatestOf({required DocumentRef ref}); + Future getLatestRefOf(DocumentRef ref); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart index 50fd4f8fdcf5..0b42deac19ab 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart @@ -4,7 +4,7 @@ import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; /// See [DatabaseDocumentsDataSource]. abstract interface class SignedDocumentDataSource implements DocumentDataLocalSource { @override - Future get({ + Future getWhere({ DocumentType? type, DocumentRef? ref, DocumentRef? refTo, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/document/document_repository_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/document/document_repository_test.dart index df3534b079cd..7f71f4ec7b32 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/document/document_repository_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/document/document_repository_test.dart @@ -66,10 +66,10 @@ void main() { ); when( - () => remoteDocuments.get(ref: template.ref), + () => remoteDocuments.get(template.ref), ).thenAnswer((_) => Future.value(template)); when( - () => remoteDocuments.get(ref: proposal.ref), + () => remoteDocuments.get(proposal.ref), ).thenAnswer((_) => Future.value(proposal)); // When @@ -97,10 +97,10 @@ void main() { template: templateRef, ); - when(() => remoteDocuments.get(ref: templateRef)).thenAnswer( + when(() => remoteDocuments.get(templateRef)).thenAnswer( (_) => Future.error(DocumentNotFoundException(ref: templateRef)), ); - when(() => remoteDocuments.get(ref: proposal.ref)).thenAnswer( + when(() => remoteDocuments.get(proposal.ref)).thenAnswer( (_) => Future.error(DocumentNotFoundException(ref: templateRef)), ); @@ -133,14 +133,14 @@ void main() { final ref = documentData.ref; - when(() => remoteDocuments.get(ref: ref)).thenAnswer((_) => Future.value(documentData)); + when(() => remoteDocuments.get(ref)).thenAnswer((_) => Future.value(documentData)); // When await repository.getDocumentData(ref: ref); await repository.getDocumentData(ref: ref); // Then - verify(() => remoteDocuments.get(ref: ref)).called(1); + verify(() => remoteDocuments.get(ref)).called(1); }, onPlatform: driftOnPlatforms, ); @@ -162,7 +162,7 @@ void main() { when(() => remoteDocuments.getLatestVersion(id)).thenAnswer((_) => Future.value(version)); when( - () => remoteDocuments.get(ref: exactRef), + () => remoteDocuments.get(exactRef), ).thenAnswer((_) => Future.value(documentData)); // When @@ -170,7 +170,7 @@ void main() { // Then verify(() => remoteDocuments.getLatestVersion(id)).called(1); - verify(() => remoteDocuments.get(ref: exactRef)).called(1); + verify(() => remoteDocuments.get(exactRef)).called(1); }, onPlatform: driftOnPlatforms, ); @@ -189,10 +189,10 @@ void main() { final proposal = DocumentDataFactory.build(template: templateRef); when( - () => remoteDocuments.get(ref: template.ref), + () => remoteDocuments.get(template.ref), ).thenAnswer((_) => Future.value(template)); when( - () => remoteDocuments.get(ref: proposal.ref), + () => remoteDocuments.get(proposal.ref), ).thenAnswer((_) => Future.value(proposal)); // When @@ -233,13 +233,13 @@ void main() { final proposal2 = DocumentDataFactory.build(template: templateRef); when( - () => remoteDocuments.get(ref: template.ref), + () => remoteDocuments.get(template.ref), ).thenAnswer((_) => Future.value(template)); when( - () => remoteDocuments.get(ref: proposal1.ref), + () => remoteDocuments.get(proposal1.ref), ).thenAnswer((_) => Future.value(proposal1)); when( - () => remoteDocuments.get(ref: proposal2.ref), + () => remoteDocuments.get(proposal2.ref), ).thenAnswer((_) => Future.value(proposal2)); // When @@ -262,7 +262,7 @@ void main() { expect(proposals[0]!.data.ref, proposal1.ref); expect(proposals[1]!.data.ref, proposal2.ref); - verify(() => remoteDocuments.get(ref: template.ref)).called(1); + verify(() => remoteDocuments.get(template.ref)).called(1); }, onPlatform: driftOnPlatforms, ); From f5e0a593e3e3e0e7a4cefbf2ea00d7e01dc3b03d Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Fri, 21 Nov 2025 12:15:02 +0100 Subject: [PATCH 25/30] docs: documents sources interfaces documentation --- .../source/document_data_local_source.dart | 40 ++++++++++++++++++- .../document/source/document_data_source.dart | 10 +++++ .../local_document_data_local_source.dart | 11 +++++ .../signed_document_data_local_source.dart | 10 +++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart index 7ce471bf992e..1da659b42167 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart @@ -1,22 +1,45 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; -/// Base interface to interact with locally document data. +/// Base interface to interact with locally stored document data (Database/Storage). +/// +/// This interface handles common CRUD operations and reactive streams for +/// both [SignedDocumentDataSource] and [DraftDataSource]. abstract interface class DocumentDataLocalSource implements DocumentDataSource { + /// Counts the number of documents matching the provided filters. + /// + /// * [type]: Filter by the [DocumentType] (e.g., Proposal, Comment). + /// * [ref]: Filter by the specific identity of the document (ID/Version). + /// * [refTo]: Filter for documents that reference *this* target [refTo]. + /// (e.g., Find all comments pointing to Proposal X). Future count({ DocumentType? type, DocumentRef? ref, DocumentRef? refTo, }); + /// Deletes documents matching the provided filters. + /// + /// * [typeNotIn]: If provided, deletes all documents *except* those + /// matching the types in this list. + /// + /// Returns the number of records deleted. Future delete({ List? typeNotIn, }); + /// Checks if a specific document exists in local storage. Future exists({required DocumentRef ref}); + /// Checks a list of [refs] and returns a subset list containing only + /// the references that actually exist in the local storage. Future> filterExisting(List refs); + /// Retrieves a list of documents matching the provided filters. + /// + /// * [latestOnly]: If `true`, only the most recent version of each + /// document ID is returned. + /// * [limit] and [offset]: Used for pagination. Future> getAll({ DocumentType? type, DocumentRef? ref, @@ -26,22 +49,36 @@ abstract interface class DocumentDataLocalSource implements DocumentDataSource { int offset, }); + /// Retrieves a single document matching the provided filters. + /// + /// Returns `null` if no document matches or if multiple match (depending on impl). + /// Generally used when the filter is expected to yield a unique result. Future getWhere({ DocumentType? type, DocumentRef? ref, DocumentRef? refTo, }); + /// Persists a single [DocumentData] object to local storage. + /// + /// If the document already exists, it should be updated (upsert). Future save({required DocumentData data}); + /// Persists multiple [DocumentData] objects to local storage in a batch. Future saveAll(Iterable data); + /// Watches for changes to a single document matching the filters. + /// + /// Emits a new value whenever the matching document is updated or inserted. Stream watch({ DocumentType? type, DocumentRef? ref, DocumentRef? refTo, }); + /// Watches for changes to a list of documents matching the filters. + /// + /// Emits a new list whenever any document matching the criteria changes. Stream> watchAll({ DocumentType? type, DocumentRef? ref, @@ -51,6 +88,7 @@ abstract interface class DocumentDataLocalSource implements DocumentDataSource { int offset, }); + /// Watches the count of documents matching the filters. Stream watchCount({ DocumentType? type, DocumentRef? ref, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart index bd9ff123a7b6..60849a3514b9 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart @@ -1,7 +1,17 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +/// A base interface for retrieving document data from any source (Local, Remote, Memory). abstract interface class DocumentDataSource { + /// Retrieves a specific document by its unique reference. + /// + /// Returns `null` if the document with the specific [DocumentRef.id] and + /// [DocumentRef.version] is not found. Future get(DocumentRef ref); + /// Resolves the reference to the latest available version of a document chain. + /// + /// If [ref] points to an older version, this returns the [DocumentRef] + /// for the most recent version of that document ID. + /// Returns `null` if the document ID does not exist in the source. Future getLatestRefOf(DocumentRef ref); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart index 4e7e65df01a8..ea2f020d3165 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart @@ -1,14 +1,25 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; +/// Interface for accessing mutable draft documents. +/// +/// Drafts are local-only, unsigned work-in-progress documents. /// See [DatabaseDraftsDataSource]. abstract interface class DraftDataSource implements DocumentDataLocalSource { + /// Deletes drafts matching the criteria. + /// + /// * [ref]: If provided, deletes the specific draft. + /// * [typeNotIn]: Deletes all drafts NOT matching these types (often used for cleanup). @override Future delete({ DocumentRef? ref, List? typeNotIn, }); + /// Updates the content of an existing draft identified by [ref]. + /// + /// This is distinct from [save] as it implies modifying the payload + /// of an existing entity without necessarily creating a new version/ID. Future update({ required DraftRef ref, required DocumentDataContent content, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart index 0b42deac19ab..e4eee4796575 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart @@ -1,8 +1,15 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; +/// Interface for accessing immutable, cryptographically signed documents. +/// +/// Signed documents are final and cannot be modified, only superseded by +/// newer versions. /// See [DatabaseDocumentsDataSource]. abstract interface class SignedDocumentDataSource implements DocumentDataLocalSource { + /// Retrieves a single signed document matching the filters. + /// + /// * [authorId]: Filters documents authored by a specific [CatalystId]. @override Future getWhere({ DocumentType? type, @@ -11,6 +18,9 @@ abstract interface class SignedDocumentDataSource implements DocumentDataLocalSo CatalystId? authorId, }); + /// Watches for changes to a list of signed documents. + /// + /// * [authorId]: Filters documents authored by a specific [CatalystId]. @override Stream> watchAll({ DocumentType? type, From ead06641cb4296cab2cc94ad59de1c0a9cb0532f Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Fri, 21 Nov 2025 12:17:37 +0100 Subject: [PATCH 26/30] chore: methods rename --- .../lib/src/document/document_repository.dart | 18 +++++------ .../database_documents_data_source.dart | 32 +++++++++---------- .../source/database_drafts_data_source.dart | 20 ++++++------ .../source/document_data_local_source.dart | 4 +-- .../signed_document_data_local_source.dart | 2 +- 5 files changed, 38 insertions(+), 38 deletions(-) diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart index abc6371c5ebf..db3007e21b95 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart @@ -256,8 +256,8 @@ final class DocumentRepositoryImpl implements DocumentRepository { @override Future> findAllVersions({required DocumentRef ref}) async { final all = switch (ref) { - DraftRef() => await _drafts.getAll(ref: ref), - SignedDocumentRef() => await _localDocuments.getAll(ref: ref), + DraftRef() => await _drafts.findAll(ref: ref), + SignedDocumentRef() => await _localDocuments.findAll(ref: ref), }..sort(); return all; @@ -267,8 +267,8 @@ final class DocumentRepositoryImpl implements DocumentRepository { Future> getAllVersionsOfId({ required String id, }) async { - final localRefs = await _localDocuments.getAll(ref: SignedDocumentRef(id: id)); - final drafts = await _drafts.getAll(ref: DraftRef(id: id)); + final localRefs = await _localDocuments.findAll(ref: SignedDocumentRef(id: id)); + final drafts = await _drafts.findAll(ref: DraftRef(id: id)); return [...drafts, ...localRefs]; } @@ -298,8 +298,8 @@ final class DocumentRepositoryImpl implements DocumentRepository { Future getLatestDocument({ CatalystId? authorId, }) async { - final latestDocument = await _localDocuments.getWhere(authorId: authorId); - final latestDraft = await _drafts.getWhere(); + final latestDocument = await _localDocuments.findFirst(authorId: authorId); + final latestDraft = await _drafts.findFirst(); return [latestDocument, latestDraft].nonNulls.sorted((a, b) => a.compareTo(b)).firstOrNull; } @@ -328,7 +328,7 @@ final class DocumentRepositoryImpl implements DocumentRepository { required DocumentRef refTo, required DocumentType type, }) { - return _localDocuments.getWhere(refTo: refTo, type: type); + return _localDocuments.findFirst(refTo: refTo, type: type); } @override @@ -384,9 +384,9 @@ final class DocumentRepositoryImpl implements DocumentRepository { bool includeLocalDrafts = false, }) async { List documents; - final localDocuments = await _localDocuments.getAll(ref: SignedDocumentRef(id: id)); + final localDocuments = await _localDocuments.findAll(ref: SignedDocumentRef(id: id)); if (includeLocalDrafts) { - final localDrafts = await _drafts.getAll(ref: DraftRef(id: id)); + final localDrafts = await _drafts.findAll(ref: DraftRef(id: id)); documents = [...localDocuments, ...localDrafts]; } else { documents = localDocuments; 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 e0971d59aaf5..dff13f9b653d 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 @@ -50,10 +50,7 @@ final class DatabaseDocumentsDataSource } @override - Future get(DocumentRef ref) => getWhere(ref: ref); - - @override - Future> getAll({ + Future> findAll({ DocumentType? type, DocumentRef? ref, DocumentRef? refTo, @@ -73,6 +70,21 @@ final class DatabaseDocumentsDataSource .then((value) => value.map((e) => e.toModel()).toList()); } + @override + Future findFirst({ + DocumentType? type, + DocumentRef? ref, + DocumentRef? refTo, + CatalystId? authorId, + }) { + return _database.documentsV2Dao + .getDocument(type: type, ref: ref, refTo: refTo, author: authorId) + .then((value) => value?.toModel()); + } + + @override + Future get(DocumentRef ref) => findFirst(ref: ref); + @override Future getLatestRefOf(DocumentRef ref) { return _database.documentsV2Dao.getLatestOf(ref); @@ -86,18 +98,6 @@ final class DatabaseDocumentsDataSource return _database.proposalsV2Dao.getProposalsTotalTask(filters: filters, nodeId: nodeId); } - @override - Future getWhere({ - DocumentType? type, - DocumentRef? ref, - DocumentRef? refTo, - CatalystId? authorId, - }) { - return _database.documentsV2Dao - .getDocument(type: type, ref: ref, refTo: refTo, author: authorId) - .then((value) => value?.toModel()); - } - @override Future save({required DocumentData data}) => saveAll([data]); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart index f51f301bec7a..ea8761ee85a3 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart @@ -40,10 +40,7 @@ final class DatabaseDraftsDataSource implements DraftDataSource { } @override - Future get(DocumentRef ref) => getWhere(ref: ref); - - @override - Future> getAll({ + Future> findAll({ DocumentType? type, DocumentRef? ref, DocumentRef? refTo, @@ -64,12 +61,7 @@ final class DatabaseDraftsDataSource implements DraftDataSource { } @override - Future getLatestRefOf(DocumentRef ref) async { - return _database.localDocumentsV2Dao.getLatestOf(ref); - } - - @override - Future getWhere({ + Future findFirst({ DocumentType? type, DocumentRef? ref, DocumentRef? refTo, @@ -79,6 +71,14 @@ final class DatabaseDraftsDataSource implements DraftDataSource { .then((value) => value?.toModel()); } + @override + Future get(DocumentRef ref) => findFirst(ref: ref); + + @override + Future getLatestRefOf(DocumentRef ref) async { + return _database.localDocumentsV2Dao.getLatestOf(ref); + } + @override Future save({required DocumentData data}) => saveAll([data]); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart index 1da659b42167..54a876afbe4c 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart @@ -40,7 +40,7 @@ abstract interface class DocumentDataLocalSource implements DocumentDataSource { /// * [latestOnly]: If `true`, only the most recent version of each /// document ID is returned. /// * [limit] and [offset]: Used for pagination. - Future> getAll({ + Future> findAll({ DocumentType? type, DocumentRef? ref, DocumentRef? refTo, @@ -53,7 +53,7 @@ abstract interface class DocumentDataLocalSource implements DocumentDataSource { /// /// Returns `null` if no document matches or if multiple match (depending on impl). /// Generally used when the filter is expected to yield a unique result. - Future getWhere({ + Future findFirst({ DocumentType? type, DocumentRef? ref, DocumentRef? refTo, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart index e4eee4796575..559475f0f746 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart @@ -11,7 +11,7 @@ abstract interface class SignedDocumentDataSource implements DocumentDataLocalSo /// /// * [authorId]: Filters documents authored by a specific [CatalystId]. @override - Future getWhere({ + Future findFirst({ DocumentType? type, DocumentRef? ref, DocumentRef? refTo, From 8a1848543428dacc241006f79541b419aeb87550 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Fri, 21 Nov 2025 12:35:11 +0100 Subject: [PATCH 27/30] chore: refTo -> referencing --- .../lib/src/comment/comment_repository.dart | 2 +- .../src/database/dao/documents_v2_dao.dart | 88 +++++++++++-------- .../dao/local_draft_documents_v2_dao.dart | 74 +++++++++------- .../lib/src/document/document_repository.dart | 40 ++++----- .../database_documents_data_source.dart | 24 ++--- .../source/database_drafts_data_source.dart | 24 ++--- .../source/document_data_local_source.dart | 14 +-- .../signed_document_data_local_source.dart | 4 +- .../lib/src/proposal/proposal_repository.dart | 22 ++--- .../database/dao/documents_v2_dao_test.dart | 28 +++--- .../lib/src/proposal/proposal_service.dart | 4 +- .../src/proposal/proposal_service_test.dart | 2 +- 12 files changed, 176 insertions(+), 150 deletions(-) diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/comment/comment_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/comment/comment_repository.dart index 31b0e28e7caf..4cf6179c352d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/comment/comment_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/comment/comment_repository.dart @@ -105,7 +105,7 @@ final class DocumentsCommentRepository implements CommentRepository { .watchDocuments( type: DocumentType.commentDocument, refGetter: (data) => data.metadata.template!, - refTo: ref, + referencing: ref, ) .map( (documents) { diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart index f11a60db5e86..e7a6396991ac 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart @@ -15,12 +15,12 @@ abstract interface class DocumentsV2Dao { /// [ref] filters by the document's own identity. /// - If [DocumentRef.isExact], counts matches for that specific version. /// - If [DocumentRef.isLoose], counts all versions of that document ID. - /// [refTo] filters documents that *reference* the given target. - /// - Example: Count all comments ([type]=comment) that point to proposal X ([refTo]=X). + /// [referencing] filters documents that *reference* the given target. + /// - Example: Count all comments ([type]=comment) that point to proposal X ([referencing]=X). Future count({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }); /// Deletes documents from the database, preserving those with types in [typeNotIn]. @@ -57,7 +57,7 @@ abstract interface class DocumentsV2Dao { Future getDocument({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CatalystId? author, }); @@ -73,7 +73,7 @@ abstract interface class DocumentsV2Dao { Future> getDocuments({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CampaignFilters? filters, bool latestOnly, int limit, @@ -106,7 +106,7 @@ abstract interface class DocumentsV2Dao { Stream watchCount({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }); /// Watches for changes to a specific document query. @@ -116,7 +116,7 @@ abstract interface class DocumentsV2Dao { Stream watchDocument({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CatalystId? author, }); @@ -130,7 +130,7 @@ abstract interface class DocumentsV2Dao { Stream> watchDocuments({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CampaignFilters? filters, bool latestOnly, int limit, @@ -153,9 +153,13 @@ class DriftDocumentsV2Dao extends DatabaseAccessor Future count({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }) { - return _queryCount(type: type, ref: ref, refTo: refTo).getSingle().then((value) => value ?? 0); + return _queryCount( + type: type, + ref: ref, + referencing: referencing, + ).getSingle().then((value) => value ?? 0); } @override @@ -226,17 +230,22 @@ class DriftDocumentsV2Dao extends DatabaseAccessor Future getDocument({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CatalystId? author, }) { - return _queryDocument(type: type, ref: ref, refTo: refTo, author: author).getSingleOrNull(); + return _queryDocument( + type: type, + ref: ref, + referencing: referencing, + author: author, + ).getSingleOrNull(); } @override Future> getDocuments({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CampaignFilters? filters, bool latestOnly = false, int limit = 200, @@ -245,7 +254,7 @@ class DriftDocumentsV2Dao extends DatabaseAccessor return _queryDocuments( type: type, ref: ref, - refTo: refTo, + referencing: referencing, filters: filters, latestOnly: latestOnly, limit: limit, @@ -302,26 +311,35 @@ class DriftDocumentsV2Dao extends DatabaseAccessor Stream watchCount({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }) { - return _queryCount(type: type, ref: ref, refTo: refTo).watchSingle().map((value) => value ?? 0); + return _queryCount( + type: type, + ref: ref, + referencing: referencing, + ).watchSingle().map((value) => value ?? 0); } @override Stream watchDocument({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CatalystId? author, }) { - return _queryDocument(type: type, ref: ref, refTo: refTo, author: author).watchSingleOrNull(); + return _queryDocument( + type: type, + ref: ref, + referencing: referencing, + author: author, + ).watchSingleOrNull(); } @override Stream> watchDocuments({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CampaignFilters? filters, bool latestOnly = false, int limit = 200, @@ -330,7 +348,7 @@ class DriftDocumentsV2Dao extends DatabaseAccessor return _queryDocuments( type: type, ref: ref, - refTo: refTo, + referencing: referencing, filters: filters, latestOnly: latestOnly, limit: limit, @@ -341,7 +359,7 @@ class DriftDocumentsV2Dao extends DatabaseAccessor Selectable _queryCount({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }) { final count = countAll(); final query = selectOnly(documentsV2)..addColumns([count]); @@ -358,11 +376,11 @@ class DriftDocumentsV2Dao extends DatabaseAccessor } } - if (refTo != null) { - query.where(documentsV2.refId.equals(refTo.id)); + if (referencing != null) { + query.where(documentsV2.refId.equals(referencing.id)); - if (refTo.isExact) { - query.where(documentsV2.refVer.equals(refTo.version!)); + if (referencing.isExact) { + query.where(documentsV2.refVer.equals(referencing.version!)); } } @@ -372,7 +390,7 @@ class DriftDocumentsV2Dao extends DatabaseAccessor Selectable _queryDocument({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CatalystId? author, }) { final query = select(documentsV2); @@ -385,11 +403,11 @@ class DriftDocumentsV2Dao extends DatabaseAccessor } } - if (refTo != null) { - query.where((tbl) => tbl.refId.equals(refTo.id)); + if (referencing != null) { + query.where((tbl) => tbl.refId.equals(referencing.id)); - if (refTo.isExact) { - query.where((tbl) => tbl.refVer.equals(refTo.version!)); + if (referencing.isExact) { + query.where((tbl) => tbl.refVer.equals(referencing.version!)); } } @@ -421,7 +439,7 @@ class DriftDocumentsV2Dao extends DatabaseAccessor SimpleSelectStatement<$DocumentsV2Table, DocumentEntityV2> _queryDocuments({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CampaignFilters? filters, required bool latestOnly, required int limit, @@ -442,11 +460,11 @@ class DriftDocumentsV2Dao extends DatabaseAccessor } } - if (refTo != null) { - query.where((tbl) => tbl.refId.equals(refTo.id)); + if (referencing != null) { + query.where((tbl) => tbl.refId.equals(referencing.id)); - if (refTo.isExact) { - query.where((tbl) => tbl.refVer.equals(refTo.version!)); + if (referencing.isExact) { + query.where((tbl) => tbl.refVer.equals(referencing.version!)); } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/local_draft_documents_v2_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/local_draft_documents_v2_dao.dart index 0c512e5d5c64..07e052212a63 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/local_draft_documents_v2_dao.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/local_draft_documents_v2_dao.dart @@ -20,9 +20,13 @@ class DriftLocalDraftDocumentsV2Dao extends DatabaseAccessor count({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }) { - return _queryCount(type: type, ref: ref, refTo: refTo).getSingle().then((value) => value ?? 0); + return _queryCount( + type: type, + ref: ref, + referencing: referencing, + ).getSingle().then((value) => value ?? 0); } @override @@ -94,16 +98,16 @@ class DriftLocalDraftDocumentsV2Dao extends DatabaseAccessor getDocument({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }) { - return _queryDocument(type: type, ref: ref, refTo: refTo).getSingleOrNull(); + return _queryDocument(type: type, ref: ref, referencing: referencing).getSingleOrNull(); } @override Future> getDocuments({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CampaignFilters? filters, bool latestOnly = false, int limit = 200, @@ -112,7 +116,7 @@ class DriftLocalDraftDocumentsV2Dao extends DatabaseAccessor watchCount({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }) { - return _queryCount(type: type, ref: ref, refTo: refTo).watchSingle().map((value) => value ?? 0); + return _queryCount( + type: type, + ref: ref, + referencing: referencing, + ).watchSingle().map((value) => value ?? 0); } @override Stream watchDocument({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }) { - return _queryDocument(type: type, ref: ref, refTo: refTo).watchSingleOrNull(); + return _queryDocument(type: type, ref: ref, referencing: referencing).watchSingleOrNull(); } @override Stream> watchDocuments({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CampaignFilters? filters, bool latestOnly = false, int limit = 200, @@ -196,7 +204,7 @@ class DriftLocalDraftDocumentsV2Dao extends DatabaseAccessor _queryCount({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }) { final count = countAll(); final query = selectOnly(localDocumentsDrafts)..addColumns([count]); @@ -224,11 +232,11 @@ class DriftLocalDraftDocumentsV2Dao extends DatabaseAccessor _queryDocument({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }) { final query = select(localDocumentsDrafts); @@ -250,11 +258,11 @@ class DriftLocalDraftDocumentsV2Dao extends DatabaseAccessor tbl.refId.equals(refTo.id)); + if (referencing != null) { + query.where((tbl) => tbl.refId.equals(referencing.id)); - if (refTo.isExact) { - query.where((tbl) => tbl.refVer.equals(refTo.version!)); + if (referencing.isExact) { + query.where((tbl) => tbl.refVer.equals(referencing.version!)); } } @@ -274,7 +282,7 @@ class DriftLocalDraftDocumentsV2Dao extends DatabaseAccessor _queryDocuments({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CampaignFilters? filters, required bool latestOnly, required int limit, @@ -295,11 +303,11 @@ class DriftLocalDraftDocumentsV2Dao extends DatabaseAccessor tbl.refId.equals(refTo.id)); + if (referencing != null) { + query.where((tbl) => tbl.refId.equals(referencing.id)); - if (refTo.isExact) { - query.where((tbl) => tbl.refVer.equals(refTo.version!)); + if (referencing.isExact) { + query.where((tbl) => tbl.refVer.equals(referencing.version!)); } } @@ -333,7 +341,7 @@ abstract interface class LocalDraftDocumentsV2Dao { Future count({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }); Future deleteWhere({ @@ -348,13 +356,13 @@ abstract interface class LocalDraftDocumentsV2Dao { Future getDocument({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }); Future> getDocuments({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CampaignFilters? filters, bool latestOnly, int limit, @@ -373,19 +381,19 @@ abstract interface class LocalDraftDocumentsV2Dao { Stream watchCount({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }); Stream watchDocument({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }); Stream> watchDocuments({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CampaignFilters? filters, bool latestOnly, int limit, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart index db3007e21b95..2f729fdf2634 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart @@ -87,14 +87,14 @@ abstract interface class DocumentRepository { /// Returns latest matching [DocumentRef] version with same id as [ref]. Future getLatestOf({required DocumentRef ref}); - /// Returns count of documents matching [refTo] id and [type]. + /// Returns count of documents matching [referencing] id and [type]. Future getRefCount({ - required DocumentRef refTo, + required DocumentRef referencing, required DocumentType type, }); Future getRefToDocumentData({ - required DocumentRef refTo, + required DocumentRef referencing, required DocumentType type, }); @@ -179,7 +179,7 @@ abstract interface class DocumentRepository { /// Emits number of matching documents Stream watchCount({ - DocumentRef? refTo, + DocumentRef? referencing, DocumentType? type, }); @@ -204,19 +204,19 @@ abstract interface class DocumentRepository { bool unique = false, bool getLocalDrafts = false, CatalystId? authorId, - DocumentRef? refTo, + DocumentRef? referencing, }); /// Looking for document with matching refTo and type. - /// It return documents data that have a reference that matches [refTo] + /// It return documents data that have a reference that matches [referencing] /// /// This method is used when we want to find a document that has a reference /// to a document that we are looking for. /// /// For example, we want to find latest document action that were made - /// on a [refTo] document. + /// on a [referencing] document. Stream watchRefToDocumentData({ - required DocumentRef refTo, + required DocumentRef referencing, required DocumentType type, }); } @@ -317,18 +317,18 @@ final class DocumentRepositoryImpl implements DocumentRepository { @override Future getRefCount({ - required DocumentRef refTo, + required DocumentRef referencing, required DocumentType type, }) { - return _localDocuments.count(refTo: refTo, type: type); + return _localDocuments.count(referencing: referencing, type: type); } @override Future getRefToDocumentData({ - required DocumentRef refTo, + required DocumentRef referencing, required DocumentType type, }) { - return _localDocuments.findFirst(refTo: refTo, type: type); + return _localDocuments.findFirst(referencing: referencing, type: type); } @override @@ -468,14 +468,14 @@ final class DocumentRepositoryImpl implements DocumentRepository { bool getLocalDrafts = false, DocumentType? type, CatalystId? authorId, - DocumentRef? refTo, + DocumentRef? referencing, }) { final localDocs = _localDocuments .watchAll( latestOnly: unique, type: type, authorId: authorId, - refTo: refTo, + referencing: referencing, limit: limit ?? 200, ) .asyncMap( @@ -519,11 +519,11 @@ final class DocumentRepositoryImpl implements DocumentRepository { @override Stream watchCount({ - DocumentRef? refTo, + DocumentRef? referencing, DocumentType? type, }) { return _localDocuments.watchCount( - refTo: refTo, + referencing: referencing, type: type, ); } @@ -557,7 +557,7 @@ final class DocumentRepositoryImpl implements DocumentRepository { bool unique = false, bool getLocalDrafts = false, CatalystId? authorId, - DocumentRef? refTo, + DocumentRef? referencing, }) { return watchAllDocuments( refGetter: refGetter, @@ -566,7 +566,7 @@ final class DocumentRepositoryImpl implements DocumentRepository { unique: unique, getLocalDrafts: getLocalDrafts, authorId: authorId, - refTo: refTo, + referencing: referencing, ); } @@ -604,11 +604,11 @@ final class DocumentRepositoryImpl implements DocumentRepository { @override Stream watchRefToDocumentData({ - required DocumentRef refTo, + required DocumentRef referencing, required DocumentType type, ValueResolver refGetter = _templateResolver, }) { - return _localDocuments.watch(refTo: refTo, type: type).distinct(); + return _localDocuments.watch(referencing: referencing, type: type).distinct(); } Future _getDraftDocumentData({ 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 dff13f9b653d..126d9813d770 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 @@ -27,9 +27,9 @@ final class DatabaseDocumentsDataSource Future count({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }) { - return _database.documentsV2Dao.count(type: type, ref: ref, refTo: refTo); + return _database.documentsV2Dao.count(type: type, ref: ref, referencing: referencing); } @override @@ -53,7 +53,7 @@ final class DatabaseDocumentsDataSource Future> findAll({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, bool latestOnly = false, int limit = 200, int offset = 0, @@ -62,7 +62,7 @@ final class DatabaseDocumentsDataSource .getDocuments( type: type, ref: ref, - refTo: refTo, + referencing: referencing, latestOnly: latestOnly, limit: limit, offset: offset, @@ -74,11 +74,11 @@ final class DatabaseDocumentsDataSource Future findFirst({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CatalystId? authorId, }) { return _database.documentsV2Dao - .getDocument(type: type, ref: ref, refTo: refTo, author: authorId) + .getDocument(type: type, ref: ref, referencing: referencing, author: authorId) .then((value) => value?.toModel()); } @@ -122,10 +122,10 @@ final class DatabaseDocumentsDataSource Stream watch({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }) { return _database.documentsV2Dao - .watchDocument(type: type, ref: ref, refTo: refTo) + .watchDocument(type: type, ref: ref, referencing: referencing) .distinct() .map((value) => value?.toModel()); } @@ -134,7 +134,7 @@ final class DatabaseDocumentsDataSource Stream> watchAll({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CatalystId? authorId, bool latestOnly = false, int limit = 200, @@ -144,7 +144,7 @@ final class DatabaseDocumentsDataSource .watchDocuments( type: type, ref: ref, - refTo: refTo, + referencing: referencing, latestOnly: latestOnly, limit: limit, offset: offset, @@ -157,13 +157,13 @@ final class DatabaseDocumentsDataSource Stream watchCount({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }) { return _database.documentsV2Dao .watchCount( type: type, ref: ref, - refTo: refTo, + referencing: referencing, ) .distinct(); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart index ea8761ee85a3..99ceb1531044 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart @@ -16,9 +16,9 @@ final class DatabaseDraftsDataSource implements DraftDataSource { Future count({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }) { - return _database.localDocumentsV2Dao.count(type: type, ref: ref, refTo: refTo); + return _database.localDocumentsV2Dao.count(type: type, ref: ref, referencing: referencing); } @override @@ -43,7 +43,7 @@ final class DatabaseDraftsDataSource implements DraftDataSource { Future> findAll({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, bool latestOnly = false, int limit = 100, int offset = 0, @@ -52,7 +52,7 @@ final class DatabaseDraftsDataSource implements DraftDataSource { .getDocuments( type: type, ref: ref, - refTo: refTo, + referencing: referencing, latestOnly: latestOnly, limit: limit, offset: offset, @@ -64,10 +64,10 @@ final class DatabaseDraftsDataSource implements DraftDataSource { Future findFirst({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }) { return _database.localDocumentsV2Dao - .getDocument(type: type, ref: ref, refTo: refTo) + .getDocument(type: type, ref: ref, referencing: referencing) .then((value) => value?.toModel()); } @@ -101,10 +101,10 @@ final class DatabaseDraftsDataSource implements DraftDataSource { Stream watch({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }) { return _database.localDocumentsV2Dao - .watchDocument(type: type, ref: ref, refTo: refTo) + .watchDocument(type: type, ref: ref, referencing: referencing) .distinct() .map((value) => value?.toModel()); } @@ -113,7 +113,7 @@ final class DatabaseDraftsDataSource implements DraftDataSource { Stream> watchAll({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, bool latestOnly = false, int limit = 100, int offset = 0, @@ -122,7 +122,7 @@ final class DatabaseDraftsDataSource implements DraftDataSource { .watchDocuments( type: type, ref: ref, - refTo: refTo, + referencing: referencing, latestOnly: latestOnly, limit: limit, offset: offset, @@ -135,13 +135,13 @@ final class DatabaseDraftsDataSource implements DraftDataSource { Stream watchCount({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }) { return _database.localDocumentsV2Dao .watchCount( type: type, ref: ref, - refTo: refTo, + referencing: referencing, ) .distinct(); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart index 54a876afbe4c..92e6af646ef2 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart @@ -10,12 +10,12 @@ abstract interface class DocumentDataLocalSource implements DocumentDataSource { /// /// * [type]: Filter by the [DocumentType] (e.g., Proposal, Comment). /// * [ref]: Filter by the specific identity of the document (ID/Version). - /// * [refTo]: Filter for documents that reference *this* target [refTo]. + /// * [referencing]: Filter for documents that reference *this* target [referencing]. /// (e.g., Find all comments pointing to Proposal X). Future count({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }); /// Deletes documents matching the provided filters. @@ -43,7 +43,7 @@ abstract interface class DocumentDataLocalSource implements DocumentDataSource { Future> findAll({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, bool latestOnly, int limit, int offset, @@ -56,7 +56,7 @@ abstract interface class DocumentDataLocalSource implements DocumentDataSource { Future findFirst({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }); /// Persists a single [DocumentData] object to local storage. @@ -73,7 +73,7 @@ abstract interface class DocumentDataLocalSource implements DocumentDataSource { Stream watch({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }); /// Watches for changes to a list of documents matching the filters. @@ -82,7 +82,7 @@ abstract interface class DocumentDataLocalSource implements DocumentDataSource { Stream> watchAll({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, bool latestOnly, int limit, int offset, @@ -92,6 +92,6 @@ abstract interface class DocumentDataLocalSource implements DocumentDataSource { Stream watchCount({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, }); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart index 559475f0f746..8633bd971a3d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/signed_document_data_local_source.dart @@ -14,7 +14,7 @@ abstract interface class SignedDocumentDataSource implements DocumentDataLocalSo Future findFirst({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CatalystId? authorId, }); @@ -25,7 +25,7 @@ abstract interface class SignedDocumentDataSource implements DocumentDataLocalSo Stream> watchAll({ DocumentType? type, DocumentRef? ref, - DocumentRef? refTo, + DocumentRef? referencing, CatalystId? authorId, bool latestOnly, int limit, 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 b1f697b21303..c07db6d69596 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 @@ -66,18 +66,18 @@ abstract interface class ProposalRepository { Future upsertDraftProposal({required DocumentData document}); Stream watchCommentsCount({ - DocumentRef? refTo, + DocumentRef? referencing, }); Stream> watchLatestProposals({int? limit}); - /// Watches for [ProposalSubmissionAction] that were made on [refTo] document. + /// Watches for [ProposalSubmissionAction] that were made on [referencing] document. /// /// As making action on document not always creates a new document ref /// we need to watch for actions on a document that has a reference to - /// [refTo] document. + /// [referencing] document. Stream watchProposalPublish({ - required DocumentRef refTo, + required DocumentRef referencing, }); Stream> watchProposalsBriefPage({ @@ -130,7 +130,7 @@ final class ProposalRepositoryImpl implements ProposalRepository { }) async { final documentData = await _documentRepository.getDocumentData(ref: ref); final commentsCount = await _documentRepository.getRefCount( - refTo: ref, + referencing: ref, type: DocumentType.commentDocument, ); final proposalPublish = await getProposalPublishForRef(ref: ref); @@ -156,7 +156,7 @@ final class ProposalRepositoryImpl implements ProposalRepository { required DocumentRef ref, }) async { final data = await _documentRepository.getRefToDocumentData( - refTo: ref, + referencing: ref, type: DocumentType.proposalActionDocument, ); @@ -253,10 +253,10 @@ final class ProposalRepositoryImpl implements ProposalRepository { @override Stream watchCommentsCount({ - DocumentRef? refTo, + DocumentRef? referencing, }) { return _documentRepository.watchCount( - refTo: refTo, + referencing: referencing, type: DocumentType.commentDocument, ); } @@ -286,17 +286,17 @@ final class ProposalRepositoryImpl implements ProposalRepository { @override Stream watchProposalPublish({ - required DocumentRef refTo, + required DocumentRef referencing, }) { return _documentRepository .watchRefToDocumentData( - refTo: refTo, + referencing: referencing, type: DocumentType.proposalActionDocument, ) .map((data) { final action = _buildProposalActionData(data); - return _getProposalPublish(ref: refTo, action: action); + return _getProposalPublish(ref: referencing, action: action); }); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart index 7e9f812d37c5..8b2c514f7efd 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart @@ -191,7 +191,7 @@ void main() { // When final result = await dao.count( - refTo: const SignedDocumentRef.loose(id: 'proposal-id'), + referencing: const SignedDocumentRef.loose(id: 'proposal-id'), ); // Then @@ -218,7 +218,7 @@ void main() { // When final result = await dao.count( - refTo: const SignedDocumentRef.exact( + referencing: const SignedDocumentRef.exact( id: 'proposal-id', version: 'proposal-ver-1', ), @@ -241,7 +241,7 @@ void main() { // When final result = await dao.count( - refTo: const SignedDocumentRef.loose(id: 'non-existent-proposal'), + referencing: const SignedDocumentRef.loose(id: 'non-existent-proposal'), ); // Then @@ -298,7 +298,7 @@ void main() { // When final result = await dao.count( type: DocumentType.proposalActionDocument, - refTo: const SignedDocumentRef.loose(id: 'proposal-id'), + referencing: const SignedDocumentRef.loose(id: 'proposal-id'), ); // Then @@ -334,7 +334,7 @@ void main() { final result = await dao.count( type: DocumentType.proposalActionDocument, ref: const SignedDocumentRef.loose(id: 'action-id'), - refTo: const SignedDocumentRef.loose(id: 'proposal-id'), + referencing: const SignedDocumentRef.loose(id: 'proposal-id'), ); // Then @@ -741,7 +741,7 @@ void main() { // When final result = await dao.getDocument( - refTo: const SignedDocumentRef.loose(id: 'proposal-id'), + referencing: const SignedDocumentRef.loose(id: 'proposal-id'), ); // Then @@ -769,7 +769,7 @@ void main() { // When final result = await dao.getDocument( - refTo: const SignedDocumentRef.exact( + referencing: const SignedDocumentRef.exact( id: 'proposal-id', version: 'proposal-ver-1', ), @@ -794,7 +794,7 @@ void main() { // When final result = await dao.getDocument( - refTo: const SignedDocumentRef.loose(id: 'non-existent-proposal'), + referencing: const SignedDocumentRef.loose(id: 'non-existent-proposal'), ); // Then @@ -868,7 +868,7 @@ void main() { // When final result = await dao.getDocument( type: DocumentType.proposalActionDocument, - refTo: const SignedDocumentRef.loose(id: 'proposal-id'), + referencing: const SignedDocumentRef.loose(id: 'proposal-id'), ); // Then @@ -898,7 +898,7 @@ void main() { // When final result = await dao.getDocument( ref: const SignedDocumentRef.loose(id: 'action-id'), - refTo: const SignedDocumentRef.loose(id: 'proposal-1'), + referencing: const SignedDocumentRef.loose(id: 'proposal-1'), ); // Then @@ -942,7 +942,7 @@ void main() { final result = await dao.getDocument( type: DocumentType.proposalActionDocument, ref: const SignedDocumentRef.loose(id: 'action-id'), - refTo: const SignedDocumentRef.loose(id: 'proposal-id'), + referencing: const SignedDocumentRef.loose(id: 'proposal-id'), ); // Then @@ -968,7 +968,7 @@ void main() { final result = await dao.getDocument( type: DocumentType.commentDocument, ref: const SignedDocumentRef.loose(id: 'action-id'), - refTo: const SignedDocumentRef.loose(id: 'proposal-id'), + referencing: const SignedDocumentRef.loose(id: 'proposal-id'), ); // Then @@ -2031,7 +2031,7 @@ void main() { // When final result = await dao.getDocuments( - refTo: const SignedDocumentRef.loose(id: 'target-1'), + referencing: const SignedDocumentRef.loose(id: 'target-1'), latestOnly: false, limit: 100, offset: 0, @@ -2058,7 +2058,7 @@ void main() { // When final result = await dao.getDocuments( - refTo: const SignedDocumentRef.exact(id: 'target', version: 'v1'), + referencing: const SignedDocumentRef.exact(id: 'target', version: 'v1'), latestOnly: false, limit: 100, offset: 0, 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 0ca64add3293..0c48b9e96726 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 @@ -554,11 +554,11 @@ final class ProposalServiceImpl implements ProposalService { final selfRef = doc.metadata.selfRef; final commentsCountStream = _proposalRepository.watchCommentsCount( - refTo: selfRef, + referencing: selfRef, ); return Rx.combineLatest2( - _proposalRepository.watchProposalPublish(refTo: selfRef), + _proposalRepository.watchProposalPublish(referencing: selfRef), commentsCountStream, (ProposalPublish? publishState, int commentsCount) { if (publishState == null) return null; diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/proposal/proposal_service_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/proposal/proposal_service_test.dart index ca81f5a0ae2b..a5aa7e8ce072 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/proposal/proposal_service_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/proposal/proposal_service_test.dart @@ -43,7 +43,7 @@ void main() { when( () => mockDocumentRepository.watchCount( - refTo: any(named: 'refTo'), + referencing: any(named: 'referencing'), type: DocumentType.commentDocument, ), ).thenAnswer((_) => Stream.fromIterable([5])); From 3a6fa4301ed9862f6def5c26c149a243f334bddf Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Fri, 21 Nov 2025 12:37:41 +0100 Subject: [PATCH 28/30] chore: typeNotIn -> excludeTypes --- .../lib/src/database/dao/documents_v2_dao.dart | 12 ++++++------ .../database/dao/local_draft_documents_v2_dao.dart | 8 ++++---- .../lib/src/document/document_repository.dart | 4 ++-- .../source/database_documents_data_source.dart | 4 ++-- .../source/database_drafts_data_source.dart | 4 ++-- .../source/document_data_local_source.dart | 4 ++-- .../source/local_document_data_local_source.dart | 4 ++-- .../src/database/dao/documents_v2_dao_test.dart | 14 +++++++------- 8 files changed, 27 insertions(+), 27 deletions(-) diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart index e7a6396991ac..f2c332a9b8b6 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart @@ -23,15 +23,15 @@ abstract interface class DocumentsV2Dao { DocumentRef? referencing, }); - /// Deletes documents from the database, preserving those with types in [typeNotIn]. + /// Deletes documents from the database, preserving those with types in [excludeTypes]. /// - /// If [typeNotIn] is null or empty, this may delete *all* documents (implementation dependent). + /// If [excludeTypes] is null or empty, this may delete *all* documents (implementation dependent). /// Typically used for cache invalidation or cleaning up old data while keeping /// certain important types (e.g. keeping local drafts or templates). /// /// Returns the number of deleted rows. Future deleteWhere({ - List? typeNotIn, + List? excludeTypes, }); /// Checks if a document exists in the database. @@ -164,12 +164,12 @@ class DriftDocumentsV2Dao extends DatabaseAccessor @override Future deleteWhere({ - List? typeNotIn, + List? excludeTypes, }) { final query = delete(documentsV2); - if (typeNotIn != null) { - query.where((tbl) => tbl.type.isNotInValues(typeNotIn)); + if (excludeTypes != null) { + query.where((tbl) => tbl.type.isNotInValues(excludeTypes)); } return query.go(); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/local_draft_documents_v2_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/local_draft_documents_v2_dao.dart index 07e052212a63..8e04799cf4b0 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/local_draft_documents_v2_dao.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/local_draft_documents_v2_dao.dart @@ -32,12 +32,12 @@ class DriftLocalDraftDocumentsV2Dao extends DatabaseAccessor deleteWhere({ DocumentRef? ref, - List? typeNotIn, + List? excludeTypes, }) { final query = delete(localDocumentsDrafts); - if (typeNotIn != null) { - query.where((tbl) => tbl.type.isNotInValues(typeNotIn)); + if (excludeTypes != null) { + query.where((tbl) => tbl.type.isNotInValues(excludeTypes)); } return query.go(); @@ -346,7 +346,7 @@ abstract interface class LocalDraftDocumentsV2Dao { Future deleteWhere({ DocumentRef? ref, - List? typeNotIn, + List? excludeTypes, }); Future exists(DocumentRef ref); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart index 2f729fdf2634..9e2e9b709acd 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart @@ -410,8 +410,8 @@ final class DocumentRepositoryImpl implements DocumentRepository { bool keepLocalDrafts = false, }) async { final deletedDrafts = keepLocalDrafts ? 0 : await _drafts.delete(); - final typeNotIn = keepLocalDrafts ? [DocumentType.proposalTemplate] : null; - final deletedDocuments = await _localDocuments.delete(typeNotIn: typeNotIn); + final excludeTypes = keepLocalDrafts ? [DocumentType.proposalTemplate] : null; + final deletedDocuments = await _localDocuments.delete(excludeTypes: excludeTypes); return deletedDrafts + deletedDocuments; } 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 126d9813d770..f34444dbdfea 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 @@ -34,9 +34,9 @@ final class DatabaseDocumentsDataSource @override Future delete({ - List? typeNotIn, + List? excludeTypes, }) { - return _database.documentsV2Dao.deleteWhere(typeNotIn: typeNotIn); + return _database.documentsV2Dao.deleteWhere(excludeTypes: excludeTypes); } @override diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart index 99ceb1531044..5acdde58e028 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart @@ -24,9 +24,9 @@ final class DatabaseDraftsDataSource implements DraftDataSource { @override Future delete({ DocumentRef? ref, - List? typeNotIn, + List? excludeTypes, }) { - return _database.localDocumentsV2Dao.deleteWhere(ref: ref, typeNotIn: typeNotIn); + return _database.localDocumentsV2Dao.deleteWhere(ref: ref, excludeTypes: excludeTypes); } @override diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart index 92e6af646ef2..f7f333ec5a7b 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart @@ -20,12 +20,12 @@ abstract interface class DocumentDataLocalSource implements DocumentDataSource { /// Deletes documents matching the provided filters. /// - /// * [typeNotIn]: If provided, deletes all documents *except* those + /// * [excludeTypes]: If provided, deletes all documents *except* those /// matching the types in this list. /// /// Returns the number of records deleted. Future delete({ - List? typeNotIn, + List? excludeTypes, }); /// Checks if a specific document exists in local storage. diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart index ea2f020d3165..43be801b8506 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart @@ -9,11 +9,11 @@ abstract interface class DraftDataSource implements DocumentDataLocalSource { /// Deletes drafts matching the criteria. /// /// * [ref]: If provided, deletes the specific draft. - /// * [typeNotIn]: Deletes all drafts NOT matching these types (often used for cleanup). + /// * [excludeTypes]: Deletes all drafts NOT matching these types (often used for cleanup). @override Future delete({ DocumentRef? ref, - List? typeNotIn, + List? excludeTypes, }); /// Updates the content of an existing draft identified by [ref]. diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart index 8b2c514f7efd..6eb01435a73b 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart @@ -1699,7 +1699,7 @@ void main() { // When final result = await dao.deleteWhere( - typeNotIn: [DocumentType.proposalDocument], + excludeTypes: [DocumentType.proposalDocument], ); // Then @@ -1739,7 +1739,7 @@ void main() { // When final result = await dao.deleteWhere( - typeNotIn: [ + excludeTypes: [ DocumentType.proposalDocument, DocumentType.proposalTemplate, ], @@ -1785,7 +1785,7 @@ void main() { await dao.saveAll(entities); // When - final result = await dao.deleteWhere(typeNotIn: []); + final result = await dao.deleteWhere(excludeTypes: []); // Then expect(result, 2); @@ -1810,7 +1810,7 @@ void main() { // When final result = await dao.deleteWhere( - typeNotIn: [DocumentType.proposalDocument], + excludeTypes: [DocumentType.proposalDocument], ); // Then @@ -1839,7 +1839,7 @@ void main() { // When final result = await dao.deleteWhere( - typeNotIn: [DocumentType.proposalDocument], + excludeTypes: [DocumentType.proposalDocument], ); // Then @@ -1880,7 +1880,7 @@ void main() { // When final result = await dao.deleteWhere( - typeNotIn: [ + excludeTypes: [ DocumentType.proposalDocument, DocumentType.proposalTemplate, DocumentType.proposalActionDocument, @@ -1906,7 +1906,7 @@ void main() { // When final result = await dao.deleteWhere( - typeNotIn: [DocumentType.proposalDocument], + excludeTypes: [DocumentType.proposalDocument], ); // Then From 5bb87d0eb980c3018a92bc1030557281d5803f7f Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Fri, 21 Nov 2025 13:07:06 +0100 Subject: [PATCH 29/30] update content --- .../lib/src/document/source/database_drafts_data_source.dart | 2 +- .../src/document/source/local_document_data_local_source.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart index 5acdde58e028..553e62e537e7 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart @@ -90,7 +90,7 @@ final class DatabaseDraftsDataSource implements DraftDataSource { } @override - Future update({ + Future updateContent({ required DraftRef ref, required DocumentDataContent content, }) async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart index 43be801b8506..8efc50347f48 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/local_document_data_local_source.dart @@ -20,7 +20,7 @@ abstract interface class DraftDataSource implements DocumentDataLocalSource { /// /// This is distinct from [save] as it implies modifying the payload /// of an existing entity without necessarily creating a new version/ID. - Future update({ + Future updateContent({ required DraftRef ref, required DocumentDataContent content, }); From 9d9356df2d951785866a8028ccc46bd3aaba9894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20Moli=C5=84ski?= <47773413+damian-molinski@users.noreply.github.com> Date: Fri, 21 Nov 2025 13:44:21 +0100 Subject: [PATCH 30/30] feat(cat-voices): smaller proposals query scope (#3747) * smaller proposals page query * update PR nr --- .../docs/performance/proposals_query.csv | 11 ++- .../src/database/dao/proposals_v2_dao.dart | 77 +++++++++---------- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/catalyst_voices/docs/performance/proposals_query.csv b/catalyst_voices/docs/performance/proposals_query.csv index 88039092a5fc..c117d4437f94 100644 --- a/catalyst_voices/docs/performance/proposals_query.csv +++ b/catalyst_voices/docs/performance/proposals_query.csv @@ -34,4 +34,13 @@ docs_count ,filer ,avg_duration ,PR ,note 7008 ,categories:finals ,0:00:01.294485 ,#3622 ,- 14008 ,categories ,0:00:02.108506 ,#3622 ,- 14008 ,categories:drafts ,0:00:01.585000 ,#3622 ,- -14008 ,categories:finals ,0:00:05.024950 ,#3622 ,- \ No newline at end of file +14008 ,categories:finals ,0:00:05.024950 ,#3622 ,- +712 ,categories ,0:00:00.139314 ,#3747 ,- +712 ,categories:drafts ,0:00:00.138364 ,#3747 ,- +712 ,categories:finals ,0:00:00.148680 ,#3747 ,- +7008 ,categories ,0:00:00.168405 ,#3747 ,- +7008 ,categories:drafts ,0:00:00.190470 ,#3747 ,- +7008 ,categories:finals ,0:00:00.167145 ,#3747 ,- +14008 ,categories ,0:00:00.253610 ,#3747 ,- +14008 ,categories:drafts ,0:00:00.266390 ,#3747 ,- +14008 ,categories:finals ,0:00:00.247054 ,#3747 ,- \ No newline at end of file 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 5da89ee5609f..a10c0b41c5a6 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 @@ -570,34 +570,25 @@ class DriftProposalsV2Dao extends DatabaseAccessor /// - 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** + /// 2. **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** + /// 3. **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** + /// 4. **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) /// - /// 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 @@ -625,24 +616,12 @@ class DriftProposalsV2Dao extends DatabaseAccessor final cteQuery = ''' - WITH latest_proposals AS ( + WITH latest_proposals AS ( SELECT id, MAX(ver) as max_ver FROM documents_v2 WHERE type = ? GROUP BY id ), - version_lists AS ( - SELECT - id, - GROUP_CONCAT(ver, ',') as version_ids_str - FROM ( - SELECT id, ver - FROM documents_v2 - WHERE type = ? - ORDER BY id, ver ASC - ) - GROUP BY id - ), latest_actions AS ( SELECT ref_id, MAX(ver) as max_action_ver FROM documents_v2 @@ -661,39 +640,46 @@ class DriftProposalsV2Dao extends DatabaseAccessor effective_proposals AS ( SELECT lp.id, + -- Business Logic: Use specific version if final, otherwise latest 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, - vl.version_ids_str + ast.action_type FROM latest_proposals lp LEFT JOIN action_status ast ON lp.id = ast.ref_id - LEFT JOIN version_lists vl ON lp.id = vl.id WHERE NOT EXISTS ( + -- Business Logic: Hide action hides all versions SELECT 1 FROM action_status hidden WHERE hidden.ref_id = lp.id AND hidden.action_type = 'hide' ) - ), - comments_count AS ( - SELECT - c.ref_id, - c.ref_ver, - COUNT(*) as count - FROM documents_v2 c - WHERE c.type = ? - GROUP BY c.ref_id, c.ref_ver ) SELECT $proposalColumns, $templateColumns, - ep.action_type, - ep.version_ids_str, - COALESCE(cc.count, 0) as comments_count, + ep.action_type, + + -- Only executes for the rows in the page + ( + SELECT GROUP_CONCAT(v_list.ver, ',') + FROM ( + SELECT ver + FROM documents_v2 v_sub + WHERE v_sub.id = p.id AND v_sub.type = ? + ORDER BY v_sub.ver ASC + ) v_list + ) as version_ids_str, + + -- Only executes for the rows in the page + ( + SELECT COUNT(*) + FROM documents_v2 c + WHERE c.ref_id = p.id AND c.ref_ver = p.ver AND c.type = ? + ) as comments_count, + COALESCE(dlm.is_favorite, 0) as is_favorite FROM documents_v2 p INNER JOIN effective_proposals ep ON p.id = ep.id AND p.ver = ep.ver - 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 = ? $whereClause @@ -714,13 +700,20 @@ class DriftProposalsV2Dao extends DatabaseAccessor return customSelect( cteQuery, variables: [ - Variable.withString(DocumentType.proposalDocument.uuid), + // CTE Variables + // latest_proposals, latest_actions, action_status Variable.withString(DocumentType.proposalDocument.uuid), Variable.withString(DocumentType.proposalActionDocument.uuid), Variable.withString(DocumentType.proposalActionDocument.uuid), + // Select Subquery Variables (Order matters!) + // version_ids_str subquery, comments_count subquery + Variable.withString(DocumentType.proposalDocument.uuid), Variable.withString(DocumentType.commentDocument.uuid), + // Main Join Variables + // template join, main WHERE Variable.withString(DocumentType.proposalTemplate.uuid), Variable.withString(DocumentType.proposalDocument.uuid), + // Limit/Offset Variable.withInt(size), Variable.withInt(page * size), ],