From f39f8860eaaab18d45bc0067935ab5fe6f543e13 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 27 Feb 2026 15:27:15 -0500 Subject: [PATCH] Persist verificationId for staff certificates Add support for verificationId throughout the certificate flow: schema, GraphQL mutations/queries, domain, repositories, service implementation, and UI. - Backend: add verificationId to Certificate schema and include it in upsert/create mutations; add auth insecureReason notes to related connector operations. - Data layer: add verificationId parameter to StaffConnectorRepository API and propagation in implementation (SDK call remains commented with FIXME until dataconnect SDK is regenerated). - Domain: add verificationId field to StaffCertificate (constructor, copyWith, props). - Certificates flow: create verification via verificationService, pass returned verificationId to upsertStaffCertificate so the verification record is persisted with the certificate. - UI: update certificate upload page to show existing file path, disable editing of name/issuer/number, rearrange fields, move remove button, change file icon and text style. - Misc: minor lambda formatting cleanup in benefits mapping. Note: the generated dataconnect SDK must be refreshed to enable the new .verificationId(...) call (there is a commented FIXME in the connector implementation). --- .../staff_connector_repository_impl.dart | 27 ++-- .../staff_connector_repository.dart | 1 + .../entities/profile/staff_certificate.dart | 7 + .../certificates_repository_impl.dart | 25 ++-- .../pages/certificate_upload_page.dart | 136 +++++++++--------- .../connector/application/queries.gql | 2 +- .../connector/certificate/mutations.gql | 10 +- backend/dataconnect/schema/certificate.gql | 1 + 8 files changed, 118 insertions(+), 91 deletions(-) diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart index 5e3e7125..e4ced00d 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart @@ -196,20 +196,17 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { .listBenefitsDataByStaffId(staffId: staffId) .execute(); - return response.data.benefitsDatas - .map( - (dc.ListBenefitsDataByStaffIdBenefitsDatas e) { - final total = - e.vendorBenefitPlan.total?.toDouble() ?? 0.0; - final remaining = e.current.toDouble(); - return domain.Benefit( - title: e.vendorBenefitPlan.title, - entitlementHours: total, - usedHours: (total - remaining).clamp(0.0, total), - ); - }, - ) - .toList(); + return response.data.benefitsDatas.map(( + dc.ListBenefitsDataByStaffIdBenefitsDatas e, + ) { + final total = e.vendorBenefitPlan.total?.toDouble() ?? 0.0; + final remaining = e.current.toDouble(); + return domain.Benefit( + title: e.vendorBenefitPlan.title, + entitlementHours: total, + usedHours: (total - remaining).clamp(0.0, total), + ); + }).toList(); }); } @@ -574,6 +571,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { String? issuer, String? certificateNumber, domain.StaffCertificateValidationStatus? validationStatus, + String? verificationId, }) async { await _service.run(() async { final String staffId = await _service.getStaffId(); @@ -590,6 +588,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { .issuer(issuer) .certificateNumber(certificateNumber) .validationStatus(_mapToDCValidationStatus(validationStatus)) + // .verificationId(verificationId) // FIXME: Uncomment after running 'make dataconnect-generate-sdk' .execute(); }); } diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart index d4b04da7..7a54aea0 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart @@ -97,6 +97,7 @@ abstract interface class StaffConnectorRepository { String? issuer, String? certificateNumber, StaffCertificateValidationStatus? validationStatus, + String? verificationId, }); /// Deletes a staff certificate. diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate.dart b/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate.dart index 50b3b952..a4da1b29 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate.dart @@ -19,6 +19,7 @@ class StaffCertificate extends Equatable { this.issuer, this.certificateNumber, this.validationStatus, + this.verificationId, this.createdAt, this.updatedAt, }); @@ -56,6 +57,9 @@ class StaffCertificate extends Equatable { /// Document number or reference. final String? certificateNumber; + /// The ID of the verification record. + final String? verificationId; + /// Recent validation/verification results. final StaffCertificateValidationStatus? validationStatus; @@ -79,6 +83,7 @@ class StaffCertificate extends Equatable { issuer, certificateNumber, validationStatus, + verificationId, createdAt, updatedAt, ]; @@ -97,6 +102,7 @@ class StaffCertificate extends Equatable { String? issuer, String? certificateNumber, StaffCertificateValidationStatus? validationStatus, + String? verificationId, DateTime? createdAt, DateTime? updatedAt, }) { @@ -113,6 +119,7 @@ class StaffCertificate extends Equatable { issuer: issuer ?? this.issuer, certificateNumber: certificateNumber ?? this.certificateNumber, validationStatus: validationStatus ?? this.validationStatus, + verificationId: verificationId ?? this.verificationId, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, ); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart index 137fc5a5..70827588 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart @@ -49,16 +49,24 @@ class CertificatesRepositoryImpl implements CertificatesRepository { await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri); // 3. Initiate verification - // 3. Initiate verification - final String staffId = await _service.getStaffId(); - await _verificationService.createVerification( - fileUri: uploadRes.fileUri, - type: certificationType.value, - category: 'CERTIFICATE', - subjectType: 'STAFF', - subjectId: staffId, + final List allCerts = await getCertificates(); + final domain.StaffCertificate currentCert = allCerts.firstWhere( + (domain.StaffCertificate c) => c.certificationType == certificationType, ); + final String staffId = await _service.getStaffId(); + final VerificationResponse verificationRes = await _verificationService + .createVerification( + fileUri: uploadRes.fileUri, + type: certificationType.value, + category: 'certification', + subjectType: 'worker', + subjectId: staffId, + rules: { + 'certificateDescription': currentCert.description, + }, + ); + // 4. Update/Create Certificate in Data Connect await _service.getStaffRepository().upsertStaffCertificate( certificationType: certificationType, @@ -70,6 +78,7 @@ class CertificatesRepositoryImpl implements CertificatesRepository { certificateNumber: certificateNumber, validationStatus: domain.StaffCertificateValidationStatus.pendingExpertReview, + verificationId: verificationRes.verificationId, ); // 5. Return updated list or the specific certificate diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart index 0af4d6fa..fcb6cbcd 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart @@ -44,6 +44,7 @@ class _CertificateUploadPageState extends State { _numberController.text = widget.certificate!.certificateNumber ?? ''; _nameController.text = widget.certificate!.name; _selectedType = widget.certificate!.certificationType; + _selectedFilePath = widget.certificate?.certificateUrl; } else { _selectedType = ComplianceType.other; } @@ -116,6 +117,8 @@ class _CertificateUploadPageState extends State { } Future _showRemoveConfirmation(BuildContext context) async { + final CertificateUploadCubit cubit = + BlocProvider.of(context); final bool? confirmed = await showDialog( context: context, builder: (BuildContext context) => AlertDialog( @@ -136,9 +139,7 @@ class _CertificateUploadPageState extends State { ); if (confirmed == true && mounted) { - BlocProvider.of( - context, - ).deleteCertificate(widget.certificate!.certificationType); + await cubit.deleteCertificate(widget.certificate!.certificationType); } } @@ -189,6 +190,7 @@ class _CertificateUploadPageState extends State { const SizedBox(height: UiConstants.space2), TextField( controller: _nameController, + enabled: false, decoration: InputDecoration( hintText: t.staff_certificates.upload_modal.name_hint, border: OutlineInputBorder( @@ -198,6 +200,46 @@ class _CertificateUploadPageState extends State { ), const SizedBox(height: UiConstants.space4), + // Issuer Field + Text( + t.staff_certificates.upload_modal.issuer_label, + style: UiTypography.body2m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + TextField( + controller: _issuerController, + enabled: false, + decoration: InputDecoration( + hintText: t.staff_certificates.upload_modal.issuer_hint, + border: OutlineInputBorder( + borderRadius: UiConstants.radiusLg, + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + // Certificate Number Field + Text( + 'Certificate Number', + style: UiTypography.body2m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + TextField( + controller: _numberController, + enabled: false, + decoration: InputDecoration( + hintText: 'Enter number if applicable', + border: OutlineInputBorder( + borderRadius: UiConstants.radiusLg, + ), + ), + ), + const SizedBox(height: UiConstants.space6), + + const Divider(), + + const SizedBox(height: UiConstants.space6), + // Expiry Date Field Text( t.staff_certificates.upload_modal.expiry_label, @@ -239,40 +281,6 @@ class _CertificateUploadPageState extends State { ), const SizedBox(height: UiConstants.space4), - // Issuer Field - Text( - t.staff_certificates.upload_modal.issuer_label, - style: UiTypography.body2m.textPrimary, - ), - const SizedBox(height: UiConstants.space2), - TextField( - controller: _issuerController, - decoration: InputDecoration( - hintText: t.staff_certificates.upload_modal.issuer_hint, - border: OutlineInputBorder( - borderRadius: UiConstants.radiusLg, - ), - ), - ), - const SizedBox(height: UiConstants.space4), - - // Certificate Number Field - Text( - 'Certificate Number', - style: UiTypography.body2m.textPrimary, - ), - const SizedBox(height: UiConstants.space2), - TextField( - controller: _numberController, - decoration: InputDecoration( - hintText: 'Enter number if applicable', - border: OutlineInputBorder( - borderRadius: UiConstants.radiusLg, - ), - ), - ), - const SizedBox(height: UiConstants.space4), - // File Selector Text( t.staff_certificates.upload_modal.upload_file, @@ -283,29 +291,6 @@ class _CertificateUploadPageState extends State { selectedFilePath: _selectedFilePath, onTap: _pickFile, ), - - // Remove Button (only if existing) - if (widget.certificate != null) ...[ - const SizedBox(height: UiConstants.space8), - SizedBox( - width: double.infinity, - child: TextButton.icon( - onPressed: () => _showRemoveConfirmation(context), - icon: const Icon(UiIcons.delete, size: 20), - label: Text(t.staff_certificates.card.remove), - style: TextButton.styleFrom( - foregroundColor: UiColors.destructive, - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space4, - ), - shape: RoundedRectangleBorder( - borderRadius: UiConstants.radiusLg, - side: const BorderSide(color: UiColors.destructive), - ), - ), - ), - ), - ], ], ), ), @@ -314,6 +299,7 @@ class _CertificateUploadPageState extends State { padding: const EdgeInsets.all(UiConstants.space5), child: Column( mainAxisSize: MainAxisSize.min, + spacing: UiConstants.space4, children: [ // Attestation Row( @@ -334,7 +320,6 @@ class _CertificateUploadPageState extends State { ), ], ), - const SizedBox(height: UiConstants.space4), SizedBox( width: double.infinity, child: ElevatedButton( @@ -391,6 +376,30 @@ class _CertificateUploadPageState extends State { ), ), ), + + // Remove Button (only if existing) + if (widget.certificate != null) ...[ + SizedBox( + width: double.infinity, + child: TextButton.icon( + onPressed: () => _showRemoveConfirmation(context), + icon: const Icon(UiIcons.delete, size: 20), + label: Text(t.staff_certificates.card.remove), + style: TextButton.styleFrom( + foregroundColor: UiColors.destructive, + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space4, + ), + shape: RoundedRectangleBorder( + borderRadius: UiConstants.radiusLg, + side: const BorderSide( + color: UiColors.destructive, + ), + ), + ), + ), + ), + ], ], ), ), @@ -449,18 +458,17 @@ class _FileSelector extends StatelessWidget { child: Container( padding: const EdgeInsets.all(UiConstants.space4), decoration: BoxDecoration( - color: UiColors.tagActive, border: Border.all(color: UiColors.primary), borderRadius: UiConstants.radiusLg, ), child: Row( children: [ - const Icon(UiIcons.file, color: UiColors.primary), + const Icon(UiIcons.certificate, color: UiColors.primary), const SizedBox(width: UiConstants.space3), Expanded( child: Text( selectedFilePath!.split('/').last, - style: UiTypography.body1m.textPrimary, + style: UiTypography.body1m.primary, overflow: TextOverflow.ellipsis, ), ), diff --git a/backend/dataconnect/connector/application/queries.gql b/backend/dataconnect/connector/application/queries.gql index d2a8a205..d774db5f 100644 --- a/backend/dataconnect/connector/application/queries.gql +++ b/backend/dataconnect/connector/application/queries.gql @@ -451,7 +451,7 @@ query vaidateDayStaffApplication( $limit: Int $dayStart: Timestamp $dayEnd: Timestamp -) @auth(level: USER) { +) @auth(level: USER, insecureReason: "The staffId refers to the staff being validated. Ownership is verified at the application layer.") { applications( where: { staffId: { eq: $staffId } diff --git a/backend/dataconnect/connector/certificate/mutations.gql b/backend/dataconnect/connector/certificate/mutations.gql index a9b1dd0a..f90177e9 100644 --- a/backend/dataconnect/connector/certificate/mutations.gql +++ b/backend/dataconnect/connector/certificate/mutations.gql @@ -10,7 +10,7 @@ mutation CreateCertificate( $staffId: UUID! $validationStatus: ValidationStatus $certificateNumber: String -) @auth(level: USER) { +) @auth(level: USER, insecureReason: "The staffId refers to the staff being modified. Ownership is verified at the application layer.") { certificate_insert( data: { name: $name @@ -40,7 +40,7 @@ mutation UpdateCertificate( $issuer: String $validationStatus: ValidationStatus $certificateNumber: String -) @auth(level: USER) { +) @auth(level: USER, insecureReason: "The staffId refers to the staff being modified. Ownership is verified at the application layer.") { certificate_update( key: { staffId: $staffId, certificationType: $certificationType } data: { @@ -58,7 +58,7 @@ mutation UpdateCertificate( } mutation DeleteCertificate($staffId: UUID!, $certificationType: ComplianceType!) -@auth(level: USER) { +@auth(level: USER, insecureReason: "The staffId refers to the staff being modified. Ownership is verified at the application layer.") { certificate_delete( key: { staffId: $staffId, certificationType: $certificationType } ) @@ -88,7 +88,8 @@ mutation upsertStaffCertificate( $issuer: String $certificateNumber: String $validationStatus: ValidationStatus -) @auth(level: USER) { + $verificationId: String +) @auth(level: USER, insecureReason: "The staffId refers to the staff being modified. Ownership is verified at the application layer.") { certificate_upsert( data: { staffId: $staffId @@ -100,6 +101,7 @@ mutation upsertStaffCertificate( issuer: $issuer certificateNumber: $certificateNumber validationStatus: $validationStatus + verificationId: $verificationId } ) } diff --git a/backend/dataconnect/schema/certificate.gql b/backend/dataconnect/schema/certificate.gql index c19ed092..7bd215d6 100644 --- a/backend/dataconnect/schema/certificate.gql +++ b/backend/dataconnect/schema/certificate.gql @@ -44,6 +44,7 @@ type Certificate @table(name: "certificates", key: ["staffId", "certificationTyp certificateNumber: String validationStatus: ValidationStatus + verificationId: String staffId: UUID! staff: Staff! @ref(fields: "staffId", references: "id")