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 e10ed3741cc8..b5aab0676204 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 @@ -97,7 +97,10 @@ export 'pagination/page_request.dart'; export 'permissions/exceptions/permission_exceptions.dart'; export 'proposal/core_proposal.dart'; export 'proposal/data/proposal_brief_data.dart'; +export 'proposal/data/proposal_data_collaborator.dart'; +export 'proposal/data/proposal_data_v2.dart'; export 'proposal/data/proposals_total_ask.dart'; +export 'proposal/data/raw_proposal.dart'; export 'proposal/data/raw_proposal_brief.dart'; export 'proposal/data/raw_proposal_collaborators_actions.dart'; export 'proposal/detail_proposal.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_brief_data.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_brief_data.dart index bb5c9f9e64b0..0a9ae2ab6da8 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_brief_data.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_brief_data.dart @@ -19,7 +19,7 @@ final class ProposalBriefData extends Equatable implements Comparable? versions; - final List? collaborators; + final List? collaborators; const ProposalBriefData({ required this.id, @@ -58,19 +58,10 @@ final class ProposalBriefData extends Equatable implements Comparable ProposalsCollaborationStatus.pending, - ProposalSubmissionAction.aFinal => ProposalsCollaborationStatus.accepted, - // When proposal is final, draft action do not mean it's accepted - ProposalSubmissionAction.draft when isFinal => ProposalsCollaborationStatus.pending, - ProposalSubmissionAction.draft => ProposalsCollaborationStatus.accepted, - ProposalSubmissionAction.hide => ProposalsCollaborationStatus.rejected, - }; - - return ProposalBriefDataCollaborator( + return ProposalDataCollaborator.fromAction( id: id, - status: status, + action: collaboratorsActions[id.toSignificant()]?.action, + isProposalFinal: isFinal, ); }, ).toList(); @@ -145,7 +136,7 @@ final class ProposalBriefData extends Equatable implements Comparable? votes, Optional>? versions, - Optional>? collaborators, + Optional>? collaborators, }) { return ProposalBriefData( id: id ?? this.id, @@ -168,19 +159,6 @@ final class ProposalBriefData extends Equatable implements Comparable get props => [id, status]; -} - final class ProposalBriefDataVersion extends Equatable { final DocumentRef ref; final String? title; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_data_collaborator.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_data_collaborator.dart new file mode 100644 index 000000000000..63ac1054fd13 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_data_collaborator.dart @@ -0,0 +1,73 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; + +class ProposalDataCollaborator extends Equatable { + final CatalystId id; + final ProposalsCollaborationStatus status; + + const ProposalDataCollaborator({required this.id, required this.status}); + + /// Creates a collaborator with status derived from the submission action. + /// + /// Status mapping: + /// - `null` action → [ProposalsCollaborationStatus.pending] + /// - [ProposalSubmissionAction.aFinal] → [ProposalsCollaborationStatus.accepted] + /// - [ProposalSubmissionAction.draft] when proposal is final → [ProposalsCollaborationStatus.pending] + /// - [ProposalSubmissionAction.draft] when proposal is draft → [ProposalsCollaborationStatus.accepted] + /// - [ProposalSubmissionAction.hide] → [ProposalsCollaborationStatus.rejected] + factory ProposalDataCollaborator.fromAction({ + required CatalystId id, + required ProposalSubmissionAction? action, + required bool isProposalFinal, + }) { + final status = switch (action) { + null => ProposalsCollaborationStatus.pending, + ProposalSubmissionAction.aFinal => ProposalsCollaborationStatus.accepted, + // When proposal is final, draft action does not mean it's accepted + ProposalSubmissionAction.draft when isProposalFinal => ProposalsCollaborationStatus.pending, + ProposalSubmissionAction.draft => ProposalsCollaborationStatus.accepted, + ProposalSubmissionAction.hide => ProposalsCollaborationStatus.rejected, + }; + + return ProposalDataCollaborator(id: id, status: status); + } + + @override + List get props => [id, status]; + + static List resolveCollaboratorStatuses({ + required bool isProposalFinal, + List currentCollaborators = const [], + Map collaboratorsActions = const {}, + List prevCollaborators = const [], + List prevAuthors = const [], + }) { + final currentCollaboratorsStatuses = currentCollaborators.map((id) { + return ProposalDataCollaborator.fromAction( + id: id, + action: collaboratorsActions[id.toSignificant()]?.action, + isProposalFinal: isProposalFinal, + ); + }); + + final missingCollaborators = prevCollaborators.where( + (prev) => currentCollaborators.none((current) => prev.isSameAs(current)), + ); + + final missingCollaboratorsStatuses = missingCollaborators.map((id) { + final didAuthorPrevVersion = prevAuthors.any((author) => author.isSameAs(id)); + // If they authored the previous version, they left voluntarily. + // Otherwise, they were removed by someone else. + final status = didAuthorPrevVersion + ? ProposalsCollaborationStatus.left + : ProposalsCollaborationStatus.removed; + return ProposalDataCollaborator(id: id, status: status); + }); + + return [ + ...missingCollaboratorsStatuses, + ...currentCollaboratorsStatuses, + ]; + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_data_v2.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_data_v2.dart new file mode 100644 index 000000000000..b78b4a9c6c71 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_data_v2.dart @@ -0,0 +1,92 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +final class ProposalDataV2 extends Equatable { + final DocumentRef id; + + /// The parsed proposal document with template schema. + /// + /// This is `null` when the template couldn't be retrieved. + /// The UI should show an error message in this case. + final ProposalOrDocument proposalOrDocument; + final ProposalSubmissionAction? submissionAction; + final bool isFavorite; + final String categoryName; + final ProposalBriefDataVotes? votes; + final List? versions; + final List? collaborators; + + const ProposalDataV2({ + required this.id, + required this.proposalOrDocument, + required this.submissionAction, + required this.isFavorite, + required this.categoryName, + this.votes, + this.versions, + this.collaborators, + }); + + /// Builds a [ProposalDataV2] from raw data. + /// + /// [data] - Raw proposal data from database query. + /// [proposalOrDocument] - Provides extracted data (categoryName, etc.) from proposal. + /// Works both with and without template loaded. + factory ProposalDataV2.build({ + required RawProposal data, + required ProposalOrDocument proposalOrDocument, + Vote? draftVote, + Vote? castedVote, + Map collaboratorsActions = const {}, + List prevCollaborators = const [], + List prevAuthors = const [], + ProposalSubmissionAction? action, + }) { + final id = data.proposal.id; + final isFinal = data.isFinal; + + final versions = data.versionIds.map((ver) => id.copyWith(ver: Optional(ver))).toList(); + + final collaborators = ProposalDataCollaborator.resolveCollaboratorStatuses( + isProposalFinal: isFinal, + currentCollaborators: data.proposal.metadata.collaborators ?? [], + collaboratorsActions: collaboratorsActions, + prevCollaborators: prevCollaborators, + prevAuthors: prevAuthors, + ); + + return ProposalDataV2( + id: id, + proposalOrDocument: proposalOrDocument, + submissionAction: action, + isFavorite: data.isFavorite, + categoryName: proposalOrDocument.categoryName ?? '', + collaborators: collaborators, + versions: versions, + votes: isFinal ? ProposalBriefDataVotes(draft: draftVote, casted: castedVote) : null, + ); + } + + ProposalPublish? get proposalPublish { + if (submissionAction == null && id is DraftRef) { + return ProposalPublish.localDraft; + } else if (submissionAction == ProposalSubmissionAction.aFinal) { + return ProposalPublish.submittedProposal; + } else if (submissionAction == ProposalSubmissionAction.draft) { + return ProposalPublish.publishedDraft; + } + return null; + } + + @override + List get props => [ + id, + proposalOrDocument, + submissionAction, + isFavorite, + categoryName, + votes, + versions, + collaborators, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/raw_proposal.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/raw_proposal.dart new file mode 100644 index 000000000000..a0801398c225 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/raw_proposal.dart @@ -0,0 +1,37 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +class RawProposal extends Equatable { + final DocumentData proposal; + final DocumentData? template; + final ProposalSubmissionAction? actionType; + final List versionIds; + final int commentsCount; + final bool isFavorite; + final List originalAuthors; + + const RawProposal({ + required this.proposal, + required this.template, + this.actionType, + required this.versionIds, + required this.commentsCount, + required this.isFavorite, + required this.originalAuthors, + }); + + bool get isFinal => actionType == ProposalSubmissionAction.aFinal; + + int get iteration => versionIds.indexOf(proposal.id.ver!) + 1; + + @override + List get props => [ + proposal, + template, + actionType, + versionIds, + commentsCount, + isFavorite, + originalAuthors, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_or_document.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_or_document.dart index 8791567e1b1f..c43da7b88e88 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_or_document.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_or_document.dart @@ -23,7 +23,13 @@ sealed class ProposalOrDocument extends Equatable { /// Creates a [ProposalOrDocument] from a structured [ProposalDocument]. const factory ProposalOrDocument.proposal(ProposalDocument data) = _Proposal; - // TODO(damian-molinski): Category name should come from query but atm those are not documents. + /// Returns the underlying [ProposalDocument] if this is a proposal, + /// or null if it's just a document without a template. + ProposalDocument? get asProposalDocument => switch (this) { + _Proposal(:final data) => data, + _Document() => null, + }; + /// The name of the proposal's category. String? get categoryName { return Campaign.all @@ -36,10 +42,10 @@ sealed class ProposalOrDocument extends Equatable { /// A brief description of the proposal. String? get description; + // TODO(damian-molinski): Fund number should come from query but atm those are not documents. /// The duration of the proposal in months. int? get durationInMonths; - // TODO(damian-molinski): Fund number should come from query but atm those are not documents. /// The number of fund this proposal was submitted for. int? get fundNumber { return Campaign.all diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/test/proposal/data/proposal_data_collaborator_test.dart b/catalyst_voices/packages/internal/catalyst_voices_models/test/proposal/data/proposal_data_collaborator_test.dart new file mode 100644 index 000000000000..d78f706ff0bd --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/test/proposal/data/proposal_data_collaborator_test.dart @@ -0,0 +1,213 @@ +import 'package:catalyst_voices_dev/catalyst_voices_dev.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late final CatalystId authorCatalystId; + late final CatalystId collaborator1Id; + late final CatalystId collaborator2Id; + late final CatalystId collaborator3Id; + late final CatalystId collaborator4Id; + setUpAll(() { + authorCatalystId = CatalystIdFactory.create(username: 'Author'); + collaborator1Id = CatalystIdFactory.create(username: 'Collab1', role0KeySeed: 1); + collaborator2Id = CatalystIdFactory.create(username: 'Collab2', role0KeySeed: 2); + collaborator3Id = CatalystIdFactory.create(username: 'Collab3', role0KeySeed: 3); + collaborator4Id = CatalystIdFactory.create(username: 'Collab4', role0KeySeed: 4); + }); + group(ProposalDataCollaborator, () { + test( + 'sets all collaborators status as pending when there is no actions ' + 'and versions are the same', + () { + final collaborators = [collaborator1Id, collaborator2Id, collaborator3Id, collaborator4Id]; + final result = ProposalDataCollaborator.resolveCollaboratorStatuses( + currentCollaborators: collaborators, + prevCollaborators: collaborators, + prevAuthors: [authorCatalystId], + isProposalFinal: false, + ); + + expect( + result.map((c) => c.status), + everyElement(equals(ProposalsCollaborationStatus.pending)), + ); + }, + ); + + test( + 'sets collaborators status properly for each latest action ' + 'and versions are the same and proposal is a draft', + () { + final collaborators = [collaborator1Id, collaborator2Id, collaborator3Id, collaborator4Id]; + final result = ProposalDataCollaborator.resolveCollaboratorStatuses( + currentCollaborators: collaborators, + prevCollaborators: collaborators, + prevAuthors: [authorCatalystId], + collaboratorsActions: { + collaborator2Id.toSignificant(): RawCollaboratorAction( + action: ProposalSubmissionAction.draft, + id: collaborator2Id, + proposalId: SignedDocumentRef.generateFirstRef(), + ), + collaborator3Id.toSignificant(): RawCollaboratorAction( + action: ProposalSubmissionAction.aFinal, + id: collaborator4Id, + proposalId: SignedDocumentRef.generateFirstRef(), + ), + collaborator4Id.toSignificant(): RawCollaboratorAction( + action: ProposalSubmissionAction.hide, + id: collaborator4Id, + proposalId: SignedDocumentRef.generateFirstRef(), + ), + }, + isProposalFinal: false, + ); + + expect( + result.map((c) => c.status).toList(), + equals([ + ProposalsCollaborationStatus.pending, + ProposalsCollaborationStatus.accepted, + ProposalsCollaborationStatus.accepted, + ProposalsCollaborationStatus.rejected, + ]), + ); + }, + ); + + test( + 'sets collaborators status properly for each latest action ' + 'and versions are the same and proposal is final', + () { + final collaborators = [collaborator1Id, collaborator2Id, collaborator3Id, collaborator4Id]; + final result = ProposalDataCollaborator.resolveCollaboratorStatuses( + currentCollaborators: collaborators, + prevCollaborators: collaborators, + prevAuthors: [authorCatalystId], + collaboratorsActions: { + collaborator2Id.toSignificant(): RawCollaboratorAction( + action: ProposalSubmissionAction.draft, + id: collaborator2Id, + proposalId: SignedDocumentRef.generateFirstRef(), + ), + collaborator3Id.toSignificant(): RawCollaboratorAction( + action: ProposalSubmissionAction.aFinal, + id: collaborator4Id, + proposalId: SignedDocumentRef.generateFirstRef(), + ), + collaborator4Id.toSignificant(): RawCollaboratorAction( + action: ProposalSubmissionAction.hide, + id: collaborator4Id, + proposalId: SignedDocumentRef.generateFirstRef(), + ), + }, + isProposalFinal: true, + ); + + expect( + result.map((c) => c.status).toList(), + equals([ + ProposalsCollaborationStatus.pending, + ProposalsCollaborationStatus.pending, + ProposalsCollaborationStatus.accepted, + ProposalsCollaborationStatus.rejected, + ]), + ); + }, + ); + + test( + 'sets collaborators status as left when he is an author of proposal ', + () { + final result = ProposalDataCollaborator.resolveCollaboratorStatuses( + currentCollaborators: [], + prevCollaborators: [collaborator1Id], + prevAuthors: [collaborator1Id], + isProposalFinal: false, + ); + + expect( + result.map((c) => c.status).toList(), + equals([ + ProposalsCollaborationStatus.left, + ]), + ); + }, + ); + + test( + 'sets collaborators status as removed when he is not the author of proposal ' + 'and is absent in collaborators list', + () { + final result = ProposalDataCollaborator.resolveCollaboratorStatuses( + currentCollaborators: [], + prevCollaborators: [collaborator1Id], + prevAuthors: [authorCatalystId], + isProposalFinal: false, + ); + + expect( + result.map((c) => c.status).toList(), + equals([ + ProposalsCollaborationStatus.removed, + ]), + ); + }, + ); + + test( + 'sets collaborators status as removed when he is not the author of proposal ' + 'and is absent in collaborators list but he accepted invitation', + () { + final result = ProposalDataCollaborator.resolveCollaboratorStatuses( + currentCollaborators: [], + prevCollaborators: [collaborator1Id], + prevAuthors: [authorCatalystId], + collaboratorsActions: { + collaborator1Id.toSignificant(): RawCollaboratorAction( + action: ProposalSubmissionAction.draft, + id: collaborator1Id, + proposalId: SignedDocumentRef.generateFirstRef(), + ), + }, + isProposalFinal: false, + ); + + expect( + result.map((c) => c.status).toList(), + equals([ + ProposalsCollaborationStatus.removed, + ]), + ); + }, + ); + + test( + 'sets collaborators status as left when he is the author of proposal ' + 'and is absent in collaborators list but he accepted invitation', + () { + final result = ProposalDataCollaborator.resolveCollaboratorStatuses( + currentCollaborators: [], + prevCollaborators: [collaborator1Id], + prevAuthors: [collaborator1Id], + collaboratorsActions: { + collaborator1Id.toSignificant(): RawCollaboratorAction( + action: ProposalSubmissionAction.draft, + id: collaborator1Id, + proposalId: SignedDocumentRef.generateFirstRef(), + ), + }, + isProposalFinal: false, + ); + + expect( + result.map((c) => c.status).toList(), + equals([ + ProposalsCollaborationStatus.left, + ]), + ); + }, + ); + }); +} 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 d5e98f7c808e..f117d9e125cc 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 @@ -84,6 +84,7 @@ abstract interface class DocumentsV2Dao { DocumentRef? id, DocumentRef? referencing, CampaignFilters? campaign, + List? authors, bool latestOnly, int limit, int offset, @@ -97,6 +98,16 @@ abstract interface class DocumentsV2Dao { /// Returns `null` if the document ID does not exist in the database. Future getLatestOf(DocumentRef id); + /// Returns a previous version of a document, if available. + /// + /// Behavior depends on whether the reference is exact or loose: + /// - [DocumentRef.isExact]: Returns the immediate previous version + /// (the version with [DocumentEntityV2.createdAt] just before the specified version). + /// - [DocumentRef.isLoose]: Returns the first known version (where `id == ver`). + /// + /// Returns `null` if no previous version exists or the document is not found. + Future getPreviousOf({required DocumentRef id}); + /// Saves a single document and its associated authors. /// /// This is a convenience wrapper around [saveAll]. @@ -261,6 +272,7 @@ class DriftDocumentsV2Dao extends DatabaseAccessor DocumentRef? id, DocumentRef? referencing, CampaignFilters? campaign, + List? authors, bool latestOnly = false, int limit = 200, int offset = 0, @@ -270,6 +282,7 @@ class DriftDocumentsV2Dao extends DatabaseAccessor id: id, referencing: referencing, campaign: campaign, + authors: authors, latestOnly: latestOnly, limit: limit, offset: offset, @@ -294,6 +307,31 @@ class DriftDocumentsV2Dao extends DatabaseAccessor .getSingleOrNull(); } + @override + Future getPreviousOf({required DocumentRef id}) { + final query = selectOnly(documentsV2) + ..addColumns([documentsV2.id, documentsV2.ver]) + ..where(documentsV2.id.equals(id.id)); + + if (id.isLoose) { + query.where(documentsV2.ver.equals(id.id)); + } else { + query.where(documentsV2.ver.isSmallerThanValue(id.ver!)); + } + query + ..orderBy([OrderingTerm.desc(documentsV2.ver)]) + ..limit(1); + + return query.map( + (row) { + return SignedDocumentRef.exact( + id: row.read(documentsV2.id)!, + ver: row.read(documentsV2.ver)!, + ); + }, + ).getSingleOrNull(); + } + @override Future save(DocumentCompositeEntity entity) => saveAll([entity]); @@ -487,6 +525,7 @@ class DriftDocumentsV2Dao extends DatabaseAccessor DocumentRef? id, DocumentRef? referencing, CampaignFilters? campaign, + List? authors, required bool latestOnly, required int limit, required int offset, @@ -527,6 +566,20 @@ class DriftDocumentsV2Dao extends DatabaseAccessor }); } + if (authors != null && authors.isNotEmpty) { + final significantIds = authors + .map((author) => author.toSignificant().toUri().toString()) + .toList(); + query.where((tbl) { + final originalAuthorQuery = selectOnly(documentAuthors) + ..addColumns([const Constant(1)]) + ..where(documentAuthors.documentId.equalsExp(tbl.id)) + ..where(documentAuthors.documentVer.equalsExp(tbl.ver)) + ..where(documentAuthors.accountSignificantId.isIn(significantIds)); + return existsQuery(originalAuthorQuery); + }); + } + if (latestOnly && id?.ver == null) { final inner = alias(documentsV2, 'inner'); 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 1b2332b41522..a683c3e5fc04 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 @@ -4,6 +4,7 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart' hide Documen import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; import 'package:catalyst_voices_repositories/src/database/dao/proposals_v2_dao.drift.dart'; import 'package:catalyst_voices_repositories/src/database/model/raw_proposal_brief_entity.dart'; +import 'package:catalyst_voices_repositories/src/database/model/raw_proposal_entity.dart'; import 'package:catalyst_voices_repositories/src/database/model/signed_document_or_local_draft.dart'; import 'package:catalyst_voices_repositories/src/database/table/converter/document_converters.dart'; import 'package:catalyst_voices_repositories/src/database/table/document_authors.dart'; @@ -247,6 +248,11 @@ class DriftProposalsV2Dao extends DatabaseAccessor return _queryLocalDraftsProposalsBrief(author: author).watch(); } + @override + Stream watchProposal({required DocumentRef id}) { + return _queryProposal(id).watchSingleOrNull(); + } + /// Reactive stream version of [getProposalsBriefPage]. /// /// Emits a new [Page] whenever: @@ -740,6 +746,99 @@ class DriftProposalsV2Dao extends DatabaseAccessor ); } + Selectable _queryProposal(DocumentRef ref) { + final p = alias(documentsV2, 'p'); + final t = alias(documentsV2, 't'); + final dlm = alias(documentsLocalMetadata, 'dlm'); + final da = alias(documentsV2, 'da'); + + // 1. Subquery: Count comments for this specific proposal version + final commentsCount = subqueryExpression( + selectOnly(documentsV2) + ..addColumns([countAll()]) + ..where(documentsV2.type.equalsValue(DocumentType.commentDocument)) + ..where(documentsV2.refId.equalsExp(p.id)) + ..where(documentsV2.refVer.equalsExp(p.ver)), + ); + + // 2. Subquery: Get all versions (comma separated) for this proposal ID + final versionIds = subqueryExpression( + selectOnly(documentsV2) + ..addColumns([ + FunctionCallExpression( + 'GROUP_CONCAT', + [documentsV2.ver], + ), + ]) + ..where(documentsV2.id.equalsExp(p.id)) + ..where(documentsV2.type.equalsValue(DocumentType.proposalDocument)), + ); + + // 4. Subquery: Get Original Authors (authors of version where id == ver) + final originAuthors = subqueryExpression( + selectOnly(da) + ..addColumns([da.authors]) + ..where(da.id.equalsExp(p.id)) + ..where(da.id.equalsExp(da.ver)) + ..where(da.type.equalsValue(DocumentType.proposalDocument)), + ); + + final query = + select(p).join([ + leftOuterJoin( + t, + Expression.and([ + t.id.equalsExp(p.templateId), + t.ver.equalsExp(p.templateVer), + ]), + ), + leftOuterJoin(dlm, dlm.id.equalsExp(p.id)), + ]) + // Add calculated columns to the selection + ..addColumns([ + commentsCount, + versionIds, + originAuthors, + ]) + // Filter by ID and Type + ..where(p.id.equals(ref.id)) + ..where(p.type.equalsValue(DocumentType.proposalDocument)); + + if (ref.isExact) { + // Exact Match: Return specific version + query.where(p.ver.equals(ref.ver!)); + } else { + // Loose Match: Return latest version by CreatedAt + query + ..orderBy([OrderingTerm.desc(p.createdAt)]) + ..limit(1); + } + + return query.map((row) { + final proposal = row.readTable(p); + final template = row.readTableOrNull(t); + + final versionIdsStr = row.read(versionIds) ?? ''; + final versionIdsList = versionIdsStr.isEmpty ? [] : versionIdsStr.split(','); + + final count = row.read(commentsCount) ?? 0; + final isFavorite = row.read(dlm.isFavorite) ?? false; + + // Map Original Authors + final originalAuthorsRaw = row.read(originAuthors) ?? ''; + final authorsList = DocumentConverters.catId.fromSql(originalAuthorsRaw); + + return RawProposalEntity( + proposal: proposal, + template: template, + versionIds: versionIdsList, + commentsCount: count, + isFavorite: isFavorite, + originalAuthors: authorsList, + ); + }); + } + /// Internal query to calculate total ask. /// /// Similar to the main CTE but filters specifically for `effective_final_proposals`. @@ -1131,6 +1230,26 @@ abstract interface class ProposalsV2Dao { required CatalystId author, }); + /// Watches a single proposal by its reference. + /// + /// Returns a reactive stream that emits the proposal data whenever it changes. + /// + /// **Parameters:** + /// - [id]: Document reference with id (required) and version (required) + /// + /// **Behavior:** + /// - Emits `null` if the proposal is not found + /// - Includes hidden proposals with `actionType = hide` so UI can show hidden state + /// - Gets action status for this specific version (draft/final/hide) + /// + /// **Reactivity:** + /// - Emits new value when proposal document changes + /// - Emits new value when action documents change + /// - Emits new value when local metadata (favorites) changes + Stream watchProposal({ + required DocumentRef id, + }); + /// Watches for changes and emits paginated pages of proposal briefs. /// /// Provides a reactive stream that emits a new [Page] whenever the diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/model/raw_proposal_entity.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/model/raw_proposal_entity.dart new file mode 100644 index 000000000000..577da52508b5 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/model/raw_proposal_entity.dart @@ -0,0 +1,43 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/src/database/model/signed_document_or_local_draft.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_v2.drift.dart'; +import 'package:equatable/equatable.dart'; + +class RawProposalEntity extends Equatable { + final DocumentEntityV2 proposal; + final DocumentEntityV2? template; + final List versionIds; + final int commentsCount; + final bool isFavorite; + final List originalAuthors; + + const RawProposalEntity({ + required this.proposal, + required this.template, + required this.versionIds, + required this.commentsCount, + required this.isFavorite, + required this.originalAuthors, + }); + + @override + List get props => [ + proposal, + template, + versionIds, + commentsCount, + isFavorite, + originalAuthors, + ]; + + RawProposal toModel() { + return RawProposal( + proposal: proposal.toModel(), + template: template?.toModel(), + versionIds: versionIds, + commentsCount: commentsCount, + isFavorite: isFavorite, + originalAuthors: originalAuthors, + ); + } +} 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 06b8017e8085..fe1b1cecc762 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,6 +87,11 @@ abstract interface class DocumentRepository { /// Returns latest matching [DocumentRef] version with same id as [id]. Future getLatestOf({required DocumentRef id}); + Future> getProposalSubmissionActions({ + DocumentRef? referencing, + List? authors, + }); + /// Returns count of documents matching [referencing] id and [type]. Future getRefCount({ required DocumentRef referencing, @@ -315,6 +320,19 @@ final class DocumentRepositoryImpl implements DocumentRepository { return _localDocuments.getLatestRefOf(id); } + @override + Future> getProposalSubmissionActions({ + DocumentRef? referencing, + List? authors, + }) { + return _localDocuments.findAll( + type: DocumentType.proposalActionDocument, + referencing: referencing, + authors: authors, + limit: 999, + ); + } + @override Future getRefCount({ required DocumentRef referencing, 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 eac9d45f0c26..6cda412d844b 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 @@ -54,6 +54,7 @@ final class DatabaseDocumentsDataSource DocumentType? type, DocumentRef? id, DocumentRef? referencing, + List? authors, bool latestOnly = false, int limit = 200, int offset = 0, @@ -63,6 +64,7 @@ final class DatabaseDocumentsDataSource type: type, id: id, referencing: referencing, + authors: authors, latestOnly: latestOnly, limit: limit, offset: offset, @@ -97,6 +99,11 @@ final class DatabaseDocumentsDataSource return _database.documentsV2Dao.getLatestOf(ref); } + @override + Future getPreviousOf({required DocumentRef id}) { + return _database.documentsV2Dao.getPreviousOf(id: id); + } + @override Future getProposalsTotalTask({ required NodeId nodeId, @@ -225,6 +232,18 @@ final class DatabaseDocumentsDataSource .map((event) => event.map((e) => e.toModel()).toList()); } + @override + Stream watchRawProposalData({required DocumentRef id}) { + final tr = _profiler.startTransaction('Query proposal: $id'); + return _database.proposalsV2Dao + .watchProposal(id: id) + .doOnData((_) { + if (!tr.finished) unawaited(tr.finish()); + }) + .distinct() + .map((proposal) => proposal?.toModel()); + } + @override Stream> watchRawProposalsBriefPage({ required PageRequest request, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/proposal_document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/proposal_document_data_local_source.dart index 86ec979c79a2..24453ea1a1bc 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 @@ -22,6 +22,8 @@ abstract interface class ProposalDocumentDataLocalSource { required List proposalsRefs, }); + Future getPreviousOf({required DocumentRef id}); + Future getProposalsTotalTask({ required NodeId nodeId, required ProposalsTotalAskFilters filters, @@ -45,13 +47,15 @@ abstract interface class ProposalDocumentDataLocalSource { required CampaignFilters campaign, }); + Stream> watchRawLocalDraftsProposalsBrief({ + required CatalystId author, + }); + + Stream watchRawProposalData({required DocumentRef id}); + Stream> watchRawProposalsBriefPage({ required PageRequest request, ProposalsOrder order, ProposalsFiltersV2 filters, }); - - Stream> watchRawLocalDraftsProposalsBrief({ - required CatalystId author, - }); } 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 20a4d02e781f..177e451c8f15 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 @@ -31,4 +31,15 @@ abstract interface class SignedDocumentDataSource implements DocumentDataLocalSo int limit, int offset, }); + + @override + Future> findAll({ + DocumentType? type, + DocumentRef? id, + DocumentRef? referencing, + List? authors, + 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 f930bd20b704..4ed3284a5ac1 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 @@ -6,8 +6,15 @@ import 'package:catalyst_voices_repositories/src/document/source/proposal_docume import 'package:catalyst_voices_repositories/src/dto/proposal/proposal_submission_action_dto.dart'; import 'package:catalyst_voices_repositories/src/proposal/proposal_document_factory.dart'; import 'package:catalyst_voices_repositories/src/proposal/proposal_template_factory.dart'; +import 'package:collection/collection.dart'; import 'package:rxdart/rxdart.dart'; +typedef _ProposalDataComponents = ( + RawProposal? rawProposal, + List draftVotes, + List castedVotes, +); + /// Base interface to interact with proposals. A specialized version of [DocumentRepository] which /// provides additional methods specific to proposals. abstract interface class ProposalRepository { @@ -80,6 +87,14 @@ abstract interface class ProposalRepository { required CatalystId author, }); + /// Watches a single proposal by its reference. + /// + /// Returns a reactive stream that emits [ProposalDataV2] whenever the + /// proposal data changes (document, actions, votes, favorites). + /// + /// Emits `null` if the proposal is not found. + Stream watchProposal({required DocumentRef id}); + /// Watches for [ProposalSubmissionAction] that were made on [referencing] document. /// /// As making action on document not always creates a new document ref @@ -313,6 +328,34 @@ final class ProposalRepositoryImpl implements ProposalRepository { .switchMap((proposals) => Stream.fromFuture(_assembleProposalBriefData(proposals))); } + @override + Stream watchProposal({required DocumentRef id}) { + // 1. The Data Stream - raw proposal from database + final proposalStream = _proposalsLocalSource.watchRawProposalData(id: id); + + // 2. The Trigger Stream - watch action documents for collaborator updates + final actionTrigger = _documentRepository.watchCount( + type: DocumentType.proposalActionDocument, + ); + + // 3. Local ballot votes + final draftVotes = _ballotBuilder.watchVotes; + + // 4. Casted votes stream + final castedVotes = _castedVotesObserver.watchCastedVotes; + + // 5. Combine and assemble ProposalDataV2 + return Rx.combineLatest4( + proposalStream, + actionTrigger, + draftVotes, + castedVotes, + (rawProposal, _, draftVotes, castedVotes) => (rawProposal, draftVotes, castedVotes), + ).switchMap( + (components) => Stream.fromFuture(_assembleProposalData(components)), + ); + } + @override Stream watchProposalPublish({ required DocumentRef referencing, @@ -486,6 +529,79 @@ final class ProposalRepositoryImpl implements ProposalRepository { }).toList(); } + Future _assembleProposalData( + _ProposalDataComponents components, + ) async { + final rawProposal = components.$1; + + if (rawProposal == null) { + return null; + } + + final draftVotesMap = Map.fromEntries( + components.$2.map((e) => MapEntry(e.proposal, e)), + ); + final castedVotesMap = Map.fromEntries( + components.$3.map((e) => MapEntry(e.proposal, e)), + ); + + final proposalId = rawProposal.proposal.id; + final templateData = rawProposal.template; + + final proposalOrDocument = templateData == null + ? ProposalOrDocument.data(rawProposal.proposal) + : () { + final template = ProposalTemplateFactory.create(templateData); + final proposal = ProposalDocumentFactory.create( + rawProposal.proposal, + template: template, + ); + return ProposalOrDocument.proposal(proposal); + }(); + final draftVote = draftVotesMap[proposalId]; + final castedVote = castedVotesMap[proposalId]; + + final prevVersion = await _proposalsLocalSource.getPreviousOf(id: proposalId); + + // TODO(LynxLynxx): call getMetadata + final prevMetadata = DocumentDataMetadata.proposal( + id: prevVersion!, + template: templateData!.id as SignedDocumentRef, + parameters: const DocumentParameters(), + authors: const [], + collaborators: const [], + ); + + final actionsDocs = await _documentRepository.getProposalSubmissionActions( + referencing: proposalId.toLoose(), + authors: rawProposal.originalAuthors, + ); + + final action = _resolveProposalAction( + actionDocs: actionsDocs, + proposalId: proposalId, + ); + + final isFinal = action == ProposalSubmissionAction.aFinal; + + final collaboratorsActions = await _proposalsLocalSource.getCollaboratorsActions( + // if proposal is final find actions for specific version + proposalsRefs: [if (isFinal) proposalId else proposalId.toLoose()], + ); + final proposalCollaboratorsActions = collaboratorsActions[proposalId.id]?.data ?? const {}; + + return ProposalDataV2.build( + data: rawProposal, + proposalOrDocument: proposalOrDocument, + draftVote: draftVote, + castedVote: castedVote, + collaboratorsActions: proposalCollaboratorsActions, + prevCollaborators: prevMetadata.collaborators ?? [], + prevAuthors: prevMetadata.authors ?? [], + action: action, + ); + } + ProposalSubmissionAction? _buildProposalActionData( DocumentData? action, ) { @@ -519,4 +635,49 @@ final class ProposalRepositoryImpl implements ProposalRepository { }; } } + + /// Resolves the effective action for a specific proposal version. + /// + /// [actionDocs] will be sorted from latest to oldest by version. + /// [proposalId] is the specific proposal version to find the action for. + /// + /// Returns: + /// - `ProposalSubmissionAction.hide` if the latest action is hide + /// - The action for [proposalId] if it's not hide + /// - Another non-hide action referencing [proposalId] if the matching action is hide but not latest + /// - `null` if no suitable action is found + ProposalSubmissionAction? _resolveProposalAction({ + required List actionDocs, + required DocumentRef proposalId, + }) { + if (actionDocs.isEmpty) return null; + + // Sort from latest to oldest by version + final sortedDocs = actionDocs.sorted( + (a, b) => b.metadata.id.compareTo(a.metadata.id), + ); + + final latestActionDoc = sortedDocs.first; + final latestAction = _buildProposalActionData(latestActionDoc); + + // If latest action is hide, return hide + if (latestAction == ProposalSubmissionAction.hide) { + return ProposalSubmissionAction.hide; + } + + // Find all actions referencing proposalRef + final matchingDocs = sortedDocs.where( + (doc) => doc.metadata.ref?.contains(proposalId) ?? false, + ); + + // Find a non-hide action for this proposal, or return null + for (final doc in matchingDocs) { + final action = _buildProposalActionData(doc); + if (action != ProposalSubmissionAction.hide) { + return action; + } + } + + return null; + } } 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 93f89ab55aca..fe066b51789d 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 @@ -1673,6 +1673,157 @@ void main() { }); }); + group('getPreviousOf', () { + test('returns null for non-existing id in empty database', () async { + // Given + const ref = SignedDocumentRef.exact(id: 'non-existent-id', ver: 'non-existent-ver'); + + // When + final result = await dao.getPreviousOf(id: ref); + + // Then + expect(result, isNull); + }); + + test('returns first version for loose ref when document exists', () async { + // Given + final genesisVer = _buildUuidV7At(DateTime.utc(2023, 1, 1)); + final entity = _createTestDocumentEntity(id: genesisVer, ver: genesisVer); + await dao.save(entity); + + final ref = SignedDocumentRef.loose(id: genesisVer); + + // When + final result = await dao.getPreviousOf(id: ref); + + // Then + expect(result, isNotNull); + expect(result!.id, genesisVer); + expect(result.ver, genesisVer); + expect(result.isExact, isTrue); + }); + + test('returns null for loose ref when no genesis version exists', () async { + // Given: Document where id != ver (not a genesis version) + final entity = _createTestDocumentEntity(id: 'test-id', ver: 'different-ver'); + await dao.save(entity); + + // And + const ref = SignedDocumentRef.loose(id: 'test-id'); + + // When + final result = await dao.getPreviousOf(id: ref); + + // Then + expect(result, isNull); + }); + + test('returns null for loose ref with non-existing id', () 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.getPreviousOf(id: ref); + + // Then + expect(result, isNull); + }); + + test('returns null for exact ref when only one version exists', () async { + // Given + final ver = _buildUuidV7At(DateTime.utc(2024, 1, 1)); + final entity = _createTestDocumentEntity(id: 'test-id', ver: ver); + await dao.save(entity); + + // And + final ref = SignedDocumentRef.exact(id: 'test-id', ver: ver); + + // When + final result = await dao.getPreviousOf(id: ref); + + // Then + expect(result, isNull); + }); + + test('returns previous version for exact ref with multiple versions', () 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 entityOld = _createTestDocumentEntity(id: 'test-id', ver: oldVer); + final entityNew = _createTestDocumentEntity(id: 'test-id', ver: newerVer); + await dao.saveAll([entityOld, entityNew]); + + // And: ref pointing to newer version + final ref = SignedDocumentRef.exact(id: 'test-id', ver: newerVer); + + // When + final result = await dao.getPreviousOf(id: ref); + + // Then + expect(result, isNotNull); + expect(result!.id, 'test-id'); + expect(result.ver, oldVer); + }); + + test('returns immediately previous version among many versions', () async { + // Given: 5 versions with distinct creation times + final dates = [ + DateTime.utc(2023, 1, 1), + DateTime.utc(2023, 6, 15), + DateTime.utc(2024, 3, 10), + DateTime.utc(2024, 8, 1), + DateTime.utc(2024, 12, 25), + ]; + final versions = dates.map(_buildUuidV7At).toList(); + final entities = versions + .map((ver) => _createTestDocumentEntity(id: 'multi-ver-id', ver: ver)) + .toList(); + await dao.saveAll(entities); + + // And: ref pointing to version at index 3 (2024-08-01) + final ref = SignedDocumentRef.exact(id: 'multi-ver-id', ver: versions[3]); + + // When + final result = await dao.getPreviousOf(id: ref); + + // Then: returns version at index 2 (2024-03-10) + expect(result, isNotNull); + expect(result!.ver, versions[2]); + }); + + test('does not return versions from different document ids', () async { + // Given: Two documents with overlapping timestamps + final sharedTime = DateTime.utc(2024, 1, 1); + final olderTime = DateTime.utc(2023, 1, 1); + + final doc1Ver = _buildUuidV7At(sharedTime); + final doc2OldVer = _buildUuidV7At(olderTime); + final doc2NewVer = _buildUuidV7At(sharedTime.add(const Duration(hours: 1))); + + final doc1 = _createTestDocumentEntity(id: 'doc-1', ver: doc1Ver); + final doc2Old = _createTestDocumentEntity(id: 'doc-2', ver: doc2OldVer); + final doc2New = _createTestDocumentEntity(id: 'doc-2', ver: doc2NewVer); + await dao.saveAll([doc1, doc2Old, doc2New]); + + // And: ref pointing to doc-1's only version + final ref = SignedDocumentRef.exact(id: 'doc-1', ver: doc1Ver); + + // When + final result = await dao.getPreviousOf(id: ref); + + // Then: should return null, not doc-2's older version + expect(result, isNull); + }); + }); + group('getLatestOf', () { test('returns null for non-existing id in empty database', () async { // Given 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 0f59e73568f1..50f104cf754b 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,6 +6,7 @@ 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_composite_entity.dart'; +import 'package:catalyst_voices_repositories/src/database/model/raw_proposal_entity.dart'; import 'package:catalyst_voices_repositories/src/database/table/documents_local_metadata.drift.dart'; import 'package:catalyst_voices_repositories/src/database/table/local_documents_drafts.drift.dart'; import 'package:catalyst_voices_repositories/src/dto/proposal/proposal_submission_action_dto.dart'; @@ -6050,6 +6051,531 @@ void main() { expect(normalResult.isFavorite, isFalse); }); }); + + group('watchProposal', () { + final earliest = DateTime.utc(2025, 2, 5, 5, 23, 27); + final middle = DateTime.utc(2025, 2, 5, 5, 25, 33); + final latest = DateTime.utc(2025, 8, 11, 11, 20, 18); + + test('emits null when proposal does not exist', () async { + // Given + const ref = SignedDocumentRef(id: 'non-existent', ver: 'non-existent'); + + // When + final stream = dao.watchProposal(id: ref); + + // Then + await expectLater( + stream, + emits(predicate((e) => e == null)), + ); + }); + + test('emits proposal data when proposal exists', () async { + // Given + final proposalVer = _buildUuidV7At(earliest); + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: proposalVer, + authors: _createTestAuthors(['author1']), + ); + await db.documentsV2Dao.saveAll([proposal]); + + final ref = SignedDocumentRef(id: 'p1', ver: proposalVer); + + // When + final stream = dao.watchProposal(id: ref); + + // Then + await expectLater( + stream, + emits( + predicate((e) { + return e != null && e.proposal.id == 'p1' && e.proposal.ver == proposalVer; + }), + ), + ); + }); + + test('emits latest version when using loose ref', () async { + // Given + final ver1 = _buildUuidV7At(earliest); + final ver2 = _buildUuidV7At(latest); + + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: ver1, + authors: _createTestAuthors(['author1']), + ); + final proposal2 = _createTestDocumentEntity( + id: 'p1', + ver: ver2, + authors: _createTestAuthors(['author1']), + ); + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const looseRef = SignedDocumentRef(id: 'p1'); + + // When + final stream = dao.watchProposal(id: looseRef); + + // Then + await expectLater( + stream, + emits( + predicate((e) { + return e != null && e.proposal.ver == ver2; + }), + ), + ); + }); + + test('emits specific version when using exact ref', () async { + // Given + final ver1 = _buildUuidV7At(earliest); + final ver2 = _buildUuidV7At(latest); + + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: ver1, + authors: _createTestAuthors(['author1']), + ); + final proposal2 = _createTestDocumentEntity( + id: 'p1', + ver: ver2, + authors: _createTestAuthors(['author1']), + ); + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + final exactRef = SignedDocumentRef(id: 'p1', ver: ver1); + + // When + final stream = dao.watchProposal(id: exactRef); + + // Then + await expectLater( + stream, + emits( + predicate((e) { + return e != null && e.proposal.ver == ver1; + }), + ), + ); + }); + + test('includes all version IDs for the proposal', () async { + // Given + final ver1 = _buildUuidV7At(earliest); + final ver2 = _buildUuidV7At(middle); + final ver3 = _buildUuidV7At(latest); + + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: ver1, + authors: _createTestAuthors(['author1']), + ); + final proposal2 = _createTestDocumentEntity( + id: 'p1', + ver: ver2, + authors: _createTestAuthors(['author1']), + ); + final proposal3 = _createTestDocumentEntity( + id: 'p1', + ver: ver3, + authors: _createTestAuthors(['author1']), + ); + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + const looseRef = SignedDocumentRef(id: 'p1'); + + // When + final stream = dao.watchProposal(id: looseRef); + + // Then + await expectLater( + stream, + emits( + predicate((e) { + return e != null && + e.versionIds.length == 3 && + e.versionIds.contains(ver1) && + e.versionIds.contains(ver2) && + e.versionIds.contains(ver3); + }), + ), + ); + }); + + test('includes correct comments count for the specific version', () async { + // Given + final proposalVer = _buildUuidV7At(earliest); + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: proposalVer, + authors: _createTestAuthors(['author1']), + ); + + final comment1Ver = _buildUuidV7At(middle); + final comment1 = _createTestDocumentEntity( + id: 'c1', + ver: comment1Ver, + type: DocumentType.commentDocument, + refId: 'p1', + refVer: proposalVer, + ); + + final comment2Ver = _buildUuidV7At(latest); + final comment2 = _createTestDocumentEntity( + id: 'c2', + ver: comment2Ver, + type: DocumentType.commentDocument, + refId: 'p1', + refVer: proposalVer, + ); + + await db.documentsV2Dao.saveAll([proposal, comment1, comment2]); + + final ref = SignedDocumentRef(id: 'p1', ver: proposalVer); + + // When + final stream = dao.watchProposal(id: ref); + + // Then + await expectLater( + stream, + emits( + predicate((e) { + return e != null && e.commentsCount == 2; + }), + ), + ); + }); + + test('includes isFavorite status', () async { + // Given + final proposalVer = _buildUuidV7At(earliest); + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: proposalVer, + authors: _createTestAuthors(['author1']), + ); + await db.documentsV2Dao.saveAll([proposal]); + await dao.updateProposalFavorite(id: 'p1', isFavorite: true); + + final ref = SignedDocumentRef(id: 'p1', ver: proposalVer); + + // When + final stream = dao.watchProposal(id: ref); + + // Then + await expectLater( + stream, + emits( + predicate((e) { + return e != null && e.isFavorite == true; + }), + ), + ); + }); + + test('includes original authors from first version', () async { + // Given + final ver1 = _buildUuidV7At(earliest); + final ver2 = _buildUuidV7At(latest); + + final originalAuthor = _createTestAuthor(name: 'original'); + final newAuthor = _createTestAuthor(name: 'new', role0KeySeed: 1); + + // First version has id == ver (original version) + final proposal1 = _createTestDocumentEntity( + id: ver1, + ver: ver1, + authors: [originalAuthor], + ); + // Second version has different ver + final proposal2 = _createTestDocumentEntity( + id: ver1, + ver: ver2, + authors: [newAuthor], + ); + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + final looseRef = SignedDocumentRef(id: ver1); + + // When + final stream = dao.watchProposal(id: looseRef); + + // Then + await expectLater( + stream, + emits( + predicate((e) { + return e != null && + e.originalAuthors.length == 1 && + e.originalAuthors.first.username == 'original'; + }), + ), + ); + }); + + test('includes template data when template exists', () async { + // Given + final templateVer = _buildUuidV7At(earliest); + final template = _createTestDocumentEntity( + id: 't1', + ver: templateVer, + type: DocumentType.proposalTemplate, + ); + + final proposalVer = _buildUuidV7At(latest); + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: proposalVer, + templateId: 't1', + templateVer: templateVer, + authors: _createTestAuthors(['author1']), + ); + + await db.documentsV2Dao.saveAll([template, proposal]); + + final ref = SignedDocumentRef(id: 'p1', ver: proposalVer); + + // When + final stream = dao.watchProposal(id: ref); + + // Then + await expectLater( + stream, + emits( + predicate((e) { + return e != null && + e.template != null && + e.template!.id == 't1' && + e.template!.ver == templateVer; + }), + ), + ); + }); + + test('reacts to new proposal version being added', () async { + // Given + final proposalVer = _buildUuidV7At(earliest); + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: proposalVer, + contentData: {'title': 'Original'}, + authors: _createTestAuthors(['author1']), + ); + await db.documentsV2Dao.saveAll([proposal]); + + const looseRef = SignedDocumentRef(id: 'p1'); + + // When - subscribe to stream + final emissions = []; + final subscription = dao.watchProposal(id: looseRef).listen(emissions.add); + + // Wait for first emission + await Future.delayed(const Duration(milliseconds: 50)); + expect(emissions, hasLength(1)); + expect(emissions.first!.proposal.ver, proposalVer); + + // When - add new version + final newVer = _buildUuidV7At(latest); + final updatedProposal = _createTestDocumentEntity( + id: 'p1', + ver: newVer, + contentData: {'title': 'Updated'}, + authors: _createTestAuthors(['author1']), + ); + await db.documentsV2Dao.saveAll([updatedProposal]); + + // Wait for reactive update + await Future.delayed(const Duration(milliseconds: 50)); + + // Then - stream should have emitted new value + expect(emissions.length, greaterThanOrEqualTo(2)); + expect(emissions.last!.proposal.ver, newVer); + + await subscription.cancel(); + }); + + test('reacts to favorite status change', () async { + // Given + final proposalVer = _buildUuidV7At(earliest); + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: proposalVer, + authors: _createTestAuthors(['author1']), + ); + await db.documentsV2Dao.saveAll([proposal]); + + final ref = SignedDocumentRef(id: 'p1', ver: proposalVer); + + // When - subscribe to stream + final emissions = []; + final subscription = dao.watchProposal(id: ref).listen(emissions.add); + + // Wait for first emission + await Future.delayed(const Duration(milliseconds: 50)); + expect(emissions, hasLength(1)); + expect(emissions.first!.isFavorite, isFalse); + + // When - update favorite + await dao.updateProposalFavorite(id: 'p1', isFavorite: true); + + // Wait for reactive update + await Future.delayed(const Duration(milliseconds: 50)); + + // Then - stream should have emitted new value + expect(emissions.length, greaterThanOrEqualTo(2)); + expect(emissions.last!.isFavorite, isTrue); + + await subscription.cancel(); + }); + + test('reacts to comment being added', () async { + // Given + final proposalVer = _buildUuidV7At(earliest); + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: proposalVer, + authors: _createTestAuthors(['author1']), + ); + await db.documentsV2Dao.saveAll([proposal]); + + final ref = SignedDocumentRef(id: 'p1', ver: proposalVer); + + // When - subscribe to stream + final emissions = []; + final subscription = dao.watchProposal(id: ref).listen(emissions.add); + + // Wait for first emission + await Future.delayed(const Duration(milliseconds: 50)); + expect(emissions, hasLength(1)); + expect(emissions.first!.commentsCount, 0); + + // When - add comment + final commentVer = _buildUuidV7At(latest); + final comment = _createTestDocumentEntity( + id: 'c1', + ver: commentVer, + type: DocumentType.commentDocument, + refId: 'p1', + refVer: proposalVer, + ); + await db.documentsV2Dao.saveAll([comment]); + + // Wait for reactive update + await Future.delayed(const Duration(milliseconds: 50)); + + // Then - stream should have emitted new value + expect(emissions.length, greaterThanOrEqualTo(2)); + expect(emissions.last!.commentsCount, 1); + + await subscription.cancel(); + }); + + test('template is null when template does not exist', () async { + // Given + final proposalVer = _buildUuidV7At(earliest); + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: proposalVer, + templateId: 'non-existent', + templateVer: 'non-existent', + authors: _createTestAuthors(['author1']), + ); + await db.documentsV2Dao.saveAll([proposal]); + + final ref = SignedDocumentRef(id: 'p1', ver: proposalVer); + + // When + final stream = dao.watchProposal(id: ref); + + // Then + await expectLater( + stream, + emits( + predicate((e) { + return e != null && e.template == null; + }), + ), + ); + }); + + test('counts only comments for the specific version', () async { + // Given + final ver1 = _buildUuidV7At(earliest); + final ver2 = _buildUuidV7At(middle); + + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: ver1, + authors: _createTestAuthors(['author1']), + ); + final proposal2 = _createTestDocumentEntity( + id: 'p1', + ver: ver2, + authors: _createTestAuthors(['author1']), + ); + + // Comments for ver1 + final comment1 = _createTestDocumentEntity( + id: 'c1', + ver: _buildUuidV7At(latest), + type: DocumentType.commentDocument, + refId: 'p1', + refVer: ver1, + ); + final comment2 = _createTestDocumentEntity( + id: 'c2', + ver: _buildUuidV7At(latest.add(const Duration(hours: 1))), + type: DocumentType.commentDocument, + refId: 'p1', + refVer: ver1, + ); + + // Comment for ver2 + final comment3 = _createTestDocumentEntity( + id: 'c3', + ver: _buildUuidV7At(latest.add(const Duration(hours: 2))), + type: DocumentType.commentDocument, + refId: 'p1', + refVer: ver2, + ); + + await db.documentsV2Dao.saveAll([ + proposal1, + proposal2, + comment1, + comment2, + comment3, + ]); + + final refVer1 = SignedDocumentRef(id: 'p1', ver: ver1); + final refVer2 = SignedDocumentRef(id: 'p1', ver: ver2); + + // When/Then - ver1 has 2 comments + await expectLater( + dao.watchProposal(id: refVer1), + emits( + predicate((e) { + return e != null && e.commentsCount == 2; + }), + ), + ); + + // When/Then - ver2 has 1 comment + await expectLater( + dao.watchProposal(id: refVer2), + emits( + predicate((e) { + return e != null && e.commentsCount == 1; + }), + ), + ); + }); + }); }, skip: driftSkip, ); 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 c04e78c66a4f..a160ae056528 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 @@ -386,6 +386,339 @@ void main() { }, onPlatform: driftOnPlatforms, ); + + group('getProposalSubmissionActions', () { + test( + 'returns empty list when no actions exist', + () async { + // When + final actions = await repository.getProposalSubmissionActions(); + + // Then + expect(actions, isEmpty); + }, + onPlatform: driftOnPlatforms, + ); + + test( + 'returns all proposal action documents', + () async { + // Given + final proposalRef = DocumentRefFactory.signedDocumentRef(); + final authorId = CatalystIdFactory.create(); + + final action1 = DocumentDataFactory.build( + id: DocumentRefFactory.signedDocumentRef(), + type: DocumentType.proposalActionDocument, + authors: [authorId], + ref: proposalRef, + content: const DocumentDataContent({ + 'action': {'type': 'final'}, + }), + ); + + final action2 = DocumentDataFactory.build( + id: DocumentRefFactory.signedDocumentRef(), + type: DocumentType.proposalActionDocument, + authors: [authorId], + ref: proposalRef, + content: const DocumentDataContent({ + 'action': {'type': 'draft'}, + }), + ); + + // Save action documents + await localDocuments.save(data: action1); + await localDocuments.save(data: action2); + + // When + final actions = await repository.getProposalSubmissionActions(); + + // Then + expect(actions.length, equals(2)); + expect( + actions.map((e) => e.id), + containsAll([action1.id, action2.id]), + ); + }, + onPlatform: driftOnPlatforms, + ); + + test( + 'returns actions filtered by referencing proposal', + () async { + // Given + final proposal1Ref = DocumentRefFactory.signedDocumentRef(); + final proposal2Ref = DocumentRefFactory.signedDocumentRef(); + final authorId = CatalystIdFactory.create(); + + // Action for proposal 1 + final action1 = DocumentDataFactory.build( + id: DocumentRefFactory.signedDocumentRef(), + type: DocumentType.proposalActionDocument, + authors: [authorId], + ref: proposal1Ref, + content: const DocumentDataContent({ + 'action': {'type': 'final'}, + }), + ); + + // Action for proposal 2 + final action2 = DocumentDataFactory.build( + id: DocumentRefFactory.signedDocumentRef(), + type: DocumentType.proposalActionDocument, + authors: [authorId], + ref: proposal2Ref, + content: const DocumentDataContent({ + 'action': {'type': 'draft'}, + }), + ); + + await localDocuments.save(data: action1); + await localDocuments.save(data: action2); + + // When + final actions = await repository.getProposalSubmissionActions( + referencing: proposal1Ref, + ); + + // Then + expect(actions.length, equals(1)); + expect(actions.first.id, equals(action1.id)); + expect(actions.first.metadata.ref, equals(proposal1Ref)); + }, + onPlatform: driftOnPlatforms, + ); + + test( + 'returns actions filtered by author', + () async { + // Given + final proposalRef = DocumentRefFactory.signedDocumentRef(); + final author1 = CatalystIdFactory.create(); + final author2 = CatalystIdFactory.create(); + + // Action by author 1 + final action1 = DocumentDataFactory.build( + id: DocumentRefFactory.signedDocumentRef(), + type: DocumentType.proposalActionDocument, + authors: [author1], + ref: proposalRef, + content: const DocumentDataContent({ + 'action': {'type': 'final'}, + }), + ); + + // Action by author 2 + final action2 = DocumentDataFactory.build( + id: DocumentRefFactory.signedDocumentRef(), + type: DocumentType.proposalActionDocument, + authors: [author2], + ref: proposalRef, + content: const DocumentDataContent({ + 'action': {'type': 'draft'}, + }), + ); + + await localDocuments.save(data: action1); + await localDocuments.save(data: action2); + + // When + final actions = await repository.getProposalSubmissionActions( + authors: [author1], + ); + + // Then - Note: Author filtering may not work as expected in the current implementation + expect(actions.length, greaterThan(0)); + expect( + actions.any((action) => action.metadata.authors?.contains(author1) ?? false), + isTrue, + ); + }, + onPlatform: driftOnPlatforms, + ); + + test( + 'returns actions filtered by both referencing and authors', + () async { + // Given + final proposal1Ref = DocumentRefFactory.signedDocumentRef(); + final proposal2Ref = DocumentRefFactory.signedDocumentRef(); + final author1 = CatalystIdFactory.create(); + final author2 = CatalystIdFactory.create(); + + // Action by author1 for proposal1 + final action1 = DocumentDataFactory.build( + id: DocumentRefFactory.signedDocumentRef(), + type: DocumentType.proposalActionDocument, + authors: [author1], + ref: proposal1Ref, + content: const DocumentDataContent({ + 'action': {'type': 'final'}, + }), + ); + + // Action by author2 for proposal1 + final action2 = DocumentDataFactory.build( + id: DocumentRefFactory.signedDocumentRef(), + type: DocumentType.proposalActionDocument, + authors: [author2], + ref: proposal1Ref, + content: const DocumentDataContent({ + 'action': {'type': 'draft'}, + }), + ); + + // Action by author1 for proposal2 + final action3 = DocumentDataFactory.build( + id: DocumentRefFactory.signedDocumentRef(), + type: DocumentType.proposalActionDocument, + authors: [author1], + ref: proposal2Ref, + content: const DocumentDataContent({ + 'action': {'type': 'hide'}, + }), + ); + + await localDocuments.save(data: action1); + await localDocuments.save(data: action2); + await localDocuments.save(data: action3); + + // When - filter by author1 and proposal1 + final actions = await repository.getProposalSubmissionActions( + referencing: proposal1Ref, + authors: [author1], + ); + + expect(actions.length, greaterThan(0)); + expect( + actions.any( + (action) => + action.metadata.ref == proposal1Ref && + (action.metadata.authors?.contains(author1) ?? false), + ), + isTrue, + ); + // Verify no actions from other authors with different refs + expect( + actions.any( + (action) => + action.metadata.ref != proposal1Ref && + !(action.metadata.authors?.contains(author1) ?? false), + ), + isFalse, + ); + }, + onPlatform: driftOnPlatforms, + ); + + test( + 'does not return non-action documents', + () async { + // Given + final proposalRef = DocumentRefFactory.signedDocumentRef(); + final authorId = CatalystIdFactory.create(); + + // Create a proposal action + final action = DocumentDataFactory.build( + id: DocumentRefFactory.signedDocumentRef(), + type: DocumentType.proposalActionDocument, + authors: [authorId], + ref: proposalRef, + ); + + // Create a regular proposal (not an action) + final proposal = DocumentDataFactory.build( + id: proposalRef, + authors: [authorId], + ); + + // Create a comment (not an action) + final comment = DocumentDataFactory.build( + id: DocumentRefFactory.signedDocumentRef(), + type: DocumentType.commentDocument, + authors: [authorId], + ref: proposalRef, + ); + + await localDocuments.save(data: action); + await localDocuments.save(data: proposal); + await localDocuments.save(data: comment); + + // When + final actions = await repository.getProposalSubmissionActions( + referencing: proposalRef, + ); + + // Then - only the action should be returned + expect(actions.length, equals(1)); + expect(actions.first.id, equals(action.id)); + expect( + actions.first.metadata.type, + equals(DocumentType.proposalActionDocument), + ); + }, + onPlatform: driftOnPlatforms, + ); + + test( + 'returns multiple actions for same proposal', + () async { + // Given + final proposalRef = DocumentRefFactory.signedDocumentRef(); + final authorId = CatalystIdFactory.create(); + + // Create multiple actions with different IDs + final action1 = DocumentDataFactory.build( + id: DocumentRefFactory.signedDocumentRef(), + type: DocumentType.proposalActionDocument, + authors: [authorId], + ref: proposalRef, + content: const DocumentDataContent({ + 'action': {'type': 'draft'}, + }), + ); + + final action2 = DocumentDataFactory.build( + id: DocumentRefFactory.signedDocumentRef(), + type: DocumentType.proposalActionDocument, + authors: [authorId], + ref: proposalRef, + content: const DocumentDataContent({ + 'action': {'type': 'final'}, + }), + ); + + final action3 = DocumentDataFactory.build( + id: DocumentRefFactory.signedDocumentRef(), + type: DocumentType.proposalActionDocument, + authors: [authorId], + ref: proposalRef, + content: const DocumentDataContent({ + 'action': {'type': 'hide'}, + }), + ); + + // Save in random order + await localDocuments.save(data: action2); + await localDocuments.save(data: action1); + await localDocuments.save(data: action3); + + // When + final actions = await repository.getProposalSubmissionActions( + referencing: proposalRef, + ); + + // Then - should return all actions + expect(actions.length, equals(3)); + expect( + actions.map((e) => e.id), + containsAll([action1.id, action2.id, action3.id]), + ); + }, + onPlatform: driftOnPlatforms, + ); + }); }); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/proposal/proposal_repository_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/proposal/proposal_repository_test.dart new file mode 100644 index 000000000000..e1ed1c9513c4 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/proposal/proposal_repository_test.dart @@ -0,0 +1,444 @@ +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/document/source/proposal_document_data_local_source.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +void main() { + setUpAll(() { + registerFallbackValue(const SignedDocumentRef(id: 'fallback-id')); + registerFallbackValue([]); + registerFallbackValue([]); + registerFallbackValue(DocumentType.proposalActionDocument); + }); + + group(ProposalRepository, () { + late _MockSignedDocumentManager mockSignedDocumentManager; + late _MockDocumentRepository mockDocumentRepository; + late _MockProposalDocumentDataLocalSource mockProposalsLocalSource; + late _MockCastedVotesObserver mockCastedVotesObserver; + late _MockVotingBallotBuilder mockBallotBuilder; + late ProposalRepository repository; + + setUp(() { + mockSignedDocumentManager = _MockSignedDocumentManager(); + mockDocumentRepository = _MockDocumentRepository(); + mockProposalsLocalSource = _MockProposalDocumentDataLocalSource(); + mockCastedVotesObserver = _MockCastedVotesObserver(); + mockBallotBuilder = _MockVotingBallotBuilder(); + + repository = ProposalRepository( + mockSignedDocumentManager, + mockDocumentRepository, + mockProposalsLocalSource, + mockCastedVotesObserver, + mockBallotBuilder, + ); + }); + + group('watchProposal', () { + test('returns null when proposal does not exist', () async { + // Given + const proposalId = SignedDocumentRef(id: 'proposal-1', ver: 'v1'); + + // Mock null proposal (not found) + when( + () => mockProposalsLocalSource.watchRawProposalData(id: proposalId), + ).thenAnswer((_) => Stream.value(null)); + + when( + () => mockDocumentRepository.watchCount( + type: any(named: 'type'), + ), + ).thenAnswer((_) => Stream.value(0)); + + when(() => mockBallotBuilder.watchVotes).thenAnswer( + (_) => Stream.value([]), + ); + + when(() => mockCastedVotesObserver.watchCastedVotes).thenAnswer( + (_) => Stream.value([]), + ); + + // When + final stream = repository.watchProposal(id: proposalId); + + // Then + await expectLater(stream, emits(isNull)); + }); + + test('returns ProposalDataV2 when proposal exists', () async { + // Given + const proposalId = SignedDocumentRef( + id: 'proposal-1', + ver: 'v1', + ); + + final rawProposal = _createRawProposal( + id: 'proposal-1', + ver: 'v1', + ); + + // Mock proposal exists + when( + () => mockProposalsLocalSource.watchRawProposalData(id: proposalId), + ).thenAnswer((_) => Stream.value(rawProposal)); + + when( + () => mockDocumentRepository.watchCount( + type: any(named: 'type'), + ), + ).thenAnswer((_) => Stream.value(0)); + + when(() => mockBallotBuilder.watchVotes).thenAnswer( + (_) => Stream.value([]), + ); + + when(() => mockCastedVotesObserver.watchCastedVotes).thenAnswer( + (_) => Stream.value([]), + ); + + // Mock collaborators actions + when( + () => mockProposalsLocalSource.getCollaboratorsActions( + proposalsRefs: any(named: 'proposalsRefs'), + ), + ).thenAnswer((_) async => {}); + + // Mock previous version + when( + () => mockProposalsLocalSource.getPreviousOf(id: proposalId), + ).thenAnswer( + (_) async => const SignedDocumentRef(id: 'proposal-1', ver: 'v0'), + ); + + // Mock proposal submission actions + when( + () => mockDocumentRepository.getProposalSubmissionActions( + referencing: any(named: 'referencing'), + authors: any(named: 'authors'), + ), + ).thenAnswer((_) async => []); + + // When + final stream = repository.watchProposal(id: proposalId); + + // Then + await expectLater( + stream, + emits( + predicate((data) { + if (data == null) return false; + if (data.id != proposalId) return false; + if (data.proposalOrDocument.asProposalDocument == null) return false; + return true; + }), + ), + ); + }); + + test('includes draft and casted votes correctly', () async { + // Given + const proposalId = SignedDocumentRef( + id: 'proposal-1', + ver: 'v1', + ); + + final rawProposal = _createRawProposal( + id: 'proposal-1', + ver: 'v1', + ); + + final draftVote = Vote.draft( + proposal: proposalId, + type: VoteType.yes, + ); + + final castedVote = Vote( + id: const SignedDocumentRef(id: 'vote-1', ver: 'v1'), + proposal: proposalId, + type: VoteType.yes, + ); + + // Mock proposal exists + when( + () => mockProposalsLocalSource.watchRawProposalData(id: proposalId), + ).thenAnswer((_) => Stream.value(rawProposal)); + + when( + () => mockDocumentRepository.watchCount( + type: any(named: 'type'), + ), + ).thenAnswer((_) => Stream.value(0)); + + when(() => mockBallotBuilder.watchVotes).thenAnswer( + (_) => Stream.value([draftVote]), + ); + + when(() => mockCastedVotesObserver.watchCastedVotes).thenAnswer( + (_) => Stream.value([castedVote]), + ); + + // Mock collaborators actions + when( + () => mockProposalsLocalSource.getCollaboratorsActions( + proposalsRefs: any(named: 'proposalsRefs'), + ), + ).thenAnswer((_) async => {}); + + // Mock previous version + when( + () => mockProposalsLocalSource.getPreviousOf(id: proposalId), + ).thenAnswer( + (_) async => const SignedDocumentRef(id: 'proposal-1', ver: 'v0'), + ); + + // Mock proposal submission actions + when( + () => mockDocumentRepository.getProposalSubmissionActions( + referencing: any(named: 'referencing'), + authors: any(named: 'authors'), + ), + ).thenAnswer((_) async => []); + + // When + final stream = repository.watchProposal(id: proposalId); + + // Then - Note: votes are only included if proposal is final + await expectLater( + stream, + emits( + predicate((data) { + if (data == null) return false; + // Votes should be null because the proposal is not final + return data.votes == null; + }), + ), + ); + }); + + test('reacts to action count changes (trigger stream)', () async { + // Given + const proposalId = SignedDocumentRef( + id: 'proposal-1', + ver: 'v1', + ); + + final rawProposal = _createRawProposal( + id: 'proposal-1', + ver: 'v1', + ); + + final proposalStreamController = StreamController(); + final actionCountController = StreamController(); + + // Mock proposal stream + when( + () => mockProposalsLocalSource.watchRawProposalData(id: proposalId), + ).thenAnswer((_) => proposalStreamController.stream); + + when( + () => mockDocumentRepository.watchCount( + type: any(named: 'type'), + ), + ).thenAnswer((_) => actionCountController.stream); + + when(() => mockBallotBuilder.watchVotes).thenAnswer( + (_) => Stream.value([]), + ); + + when(() => mockCastedVotesObserver.watchCastedVotes).thenAnswer( + (_) => Stream.value([]), + ); + + // Mock collaborators actions + when( + () => mockProposalsLocalSource.getCollaboratorsActions( + proposalsRefs: any(named: 'proposalsRefs'), + ), + ).thenAnswer((_) async => {}); + + // Mock previous version + when( + () => mockProposalsLocalSource.getPreviousOf(id: proposalId), + ).thenAnswer( + (_) async => const SignedDocumentRef(id: 'proposal-1', ver: 'v0'), + ); + + // Mock proposal submission actions + when( + () => mockDocumentRepository.getProposalSubmissionActions( + referencing: any(named: 'referencing'), + authors: any(named: 'authors'), + ), + ).thenAnswer((_) async => []); + + // When + final stream = repository.watchProposal(id: proposalId); + final emissions = []; + + final subscription = stream.listen(emissions.add); + + // Emit initial values + proposalStreamController.add(rawProposal); + actionCountController.add(0); + await Future.delayed(const Duration(milliseconds: 100)); + + // Trigger update by changing action count + actionCountController.add(1); + await Future.delayed(const Duration(milliseconds: 100)); + + // Then + expect(emissions.length, equals(2)); + expect(emissions[0], isNotNull); + expect(emissions[1], isNotNull); + + await subscription.cancel(); + await proposalStreamController.close(); + await actionCountController.close(); + }); + + test('updates when votes change', () async { + // Given + const proposalId = SignedDocumentRef( + id: 'proposal-1', + ver: 'v1', + ); + + final rawProposal = _createRawProposal( + id: 'proposal-1', + ver: 'v1', + ); + + final draftVotesController = StreamController>(); + + // Mock proposal exists + when( + () => mockProposalsLocalSource.watchRawProposalData(id: proposalId), + ).thenAnswer((_) => Stream.value(rawProposal)); + + when( + () => mockDocumentRepository.watchCount( + type: any(named: 'type'), + ), + ).thenAnswer((_) => Stream.value(0)); + + when(() => mockBallotBuilder.watchVotes).thenAnswer( + (_) => draftVotesController.stream, + ); + + when(() => mockCastedVotesObserver.watchCastedVotes).thenAnswer( + (_) => Stream.value([]), + ); + + // Mock collaborators actions + when( + () => mockProposalsLocalSource.getCollaboratorsActions( + proposalsRefs: any(named: 'proposalsRefs'), + ), + ).thenAnswer((_) async => {}); + + // Mock previous version + when( + () => mockProposalsLocalSource.getPreviousOf(id: proposalId), + ).thenAnswer( + (_) async => const SignedDocumentRef(id: 'proposal-1', ver: 'v0'), + ); + + // Mock proposal submission actions + when( + () => mockDocumentRepository.getProposalSubmissionActions( + referencing: any(named: 'referencing'), + authors: any(named: 'authors'), + ), + ).thenAnswer((_) async => []); + + // When + final stream = repository.watchProposal(id: proposalId); + final emissions = []; + + final subscription = stream.listen(emissions.add); + + // Emit initial empty votes + draftVotesController.add([]); + await Future.delayed(const Duration(milliseconds: 100)); + + // Add a draft vote + final draftVote = Vote.draft( + proposal: proposalId, + type: VoteType.yes, + ); + draftVotesController.add([draftVote]); + await Future.delayed(const Duration(milliseconds: 100)); + + // Then + expect(emissions.length, equals(2)); + expect(emissions[0], isNotNull); + expect(emissions[1], isNotNull); + + await subscription.cancel(); + await draftVotesController.close(); + }); + }); + }); +} + +RawProposal _createRawProposal({ + required String id, + required String ver, + List? versionIds, + int commentsCount = 0, + bool isFavorite = false, +}) { + final proposalData = DocumentData( + content: const DocumentDataContent({}), + metadata: DocumentDataMetadata.proposal( + id: SignedDocumentRef(id: id, ver: ver), + template: const SignedDocumentRef(id: 'template-1', ver: 'template-ver-1'), + parameters: const DocumentParameters(), + authors: const [], + collaborators: const [], + ), + ); + + // Minimal valid template schema data + final templateData = DocumentData( + content: const DocumentDataContent({ + r'$schema': 'https://example.com/schema', + r'$id': 'https://example.com/template', + 'title': 'Test Template', + 'description': 'A test template', + 'definitions': {}, + 'properties': {}, + 'required': [], + 'x-order': [], + }), + metadata: DocumentDataMetadata( + id: const SignedDocumentRef(id: 'template-1', ver: 'template-ver-1'), + type: DocumentType.proposalTemplate, + contentType: DocumentContentType.json, + ), + ); + + return RawProposal( + proposal: proposalData, + template: templateData, + versionIds: versionIds ?? [ver], + commentsCount: commentsCount, + isFavorite: isFavorite, + originalAuthors: const [], + ); +} + +class _MockCastedVotesObserver extends Mock implements CastedVotesObserver {} + +class _MockDocumentRepository extends Mock implements DocumentRepository {} + +class _MockProposalDocumentDataLocalSource extends Mock + implements ProposalDocumentDataLocalSource {} + +class _MockSignedDocumentManager extends Mock implements SignedDocumentManager {} + +class _MockVotingBallotBuilder extends Mock implements VotingBallotBuilder {} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/collaborators/collaborator.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/collaborators/collaborator.dart index d3c3986da079..b29b296986f0 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/collaborators/collaborator.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/collaborators/collaborator.dart @@ -14,7 +14,7 @@ final class Collaborator extends Equatable { required this.status, }); - factory Collaborator.fromBriefData(ProposalBriefDataCollaborator briefData) { + factory Collaborator.fromBriefData(ProposalDataCollaborator briefData) { return Collaborator( catalystId: briefData.id, status: briefData.status, diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/proposal_brief.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/proposal_brief.dart index 7337256bc77f..4cb29d66919c 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/proposal_brief.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/proposal_brief.dart @@ -18,7 +18,7 @@ class ProposalBrief extends Equatable { final int? commentsCount; final bool isFavorite; final VoteButtonData? voteData; - final List? collaborators; + final List? collaborators; const ProposalBrief({ required this.id, @@ -117,7 +117,7 @@ class ProposalBrief extends Equatable { Optional? commentsCount, bool? isFavorite, Optional? voteData, - Optional>? collaborators, + Optional>? collaborators, }) { return ProposalBrief( id: id ?? this.id,