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).
This commit is contained in:
Achintha Isuru
2026-02-27 15:27:15 -05:00
parent c534584836
commit f39f8860ea
8 changed files with 118 additions and 91 deletions

View File

@@ -196,20 +196,17 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
.listBenefitsDataByStaffId(staffId: staffId) .listBenefitsDataByStaffId(staffId: staffId)
.execute(); .execute();
return response.data.benefitsDatas return response.data.benefitsDatas.map((
.map( dc.ListBenefitsDataByStaffIdBenefitsDatas e,
(dc.ListBenefitsDataByStaffIdBenefitsDatas e) { ) {
final total = final total = e.vendorBenefitPlan.total?.toDouble() ?? 0.0;
e.vendorBenefitPlan.total?.toDouble() ?? 0.0;
final remaining = e.current.toDouble(); final remaining = e.current.toDouble();
return domain.Benefit( return domain.Benefit(
title: e.vendorBenefitPlan.title, title: e.vendorBenefitPlan.title,
entitlementHours: total, entitlementHours: total,
usedHours: (total - remaining).clamp(0.0, total), usedHours: (total - remaining).clamp(0.0, total),
); );
}, }).toList();
)
.toList();
}); });
} }
@@ -574,6 +571,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
String? issuer, String? issuer,
String? certificateNumber, String? certificateNumber,
domain.StaffCertificateValidationStatus? validationStatus, domain.StaffCertificateValidationStatus? validationStatus,
String? verificationId,
}) async { }) async {
await _service.run(() async { await _service.run(() async {
final String staffId = await _service.getStaffId(); final String staffId = await _service.getStaffId();
@@ -590,6 +588,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
.issuer(issuer) .issuer(issuer)
.certificateNumber(certificateNumber) .certificateNumber(certificateNumber)
.validationStatus(_mapToDCValidationStatus(validationStatus)) .validationStatus(_mapToDCValidationStatus(validationStatus))
// .verificationId(verificationId) // FIXME: Uncomment after running 'make dataconnect-generate-sdk'
.execute(); .execute();
}); });
} }

View File

@@ -97,6 +97,7 @@ abstract interface class StaffConnectorRepository {
String? issuer, String? issuer,
String? certificateNumber, String? certificateNumber,
StaffCertificateValidationStatus? validationStatus, StaffCertificateValidationStatus? validationStatus,
String? verificationId,
}); });
/// Deletes a staff certificate. /// Deletes a staff certificate.

View File

@@ -19,6 +19,7 @@ class StaffCertificate extends Equatable {
this.issuer, this.issuer,
this.certificateNumber, this.certificateNumber,
this.validationStatus, this.validationStatus,
this.verificationId,
this.createdAt, this.createdAt,
this.updatedAt, this.updatedAt,
}); });
@@ -56,6 +57,9 @@ class StaffCertificate extends Equatable {
/// Document number or reference. /// Document number or reference.
final String? certificateNumber; final String? certificateNumber;
/// The ID of the verification record.
final String? verificationId;
/// Recent validation/verification results. /// Recent validation/verification results.
final StaffCertificateValidationStatus? validationStatus; final StaffCertificateValidationStatus? validationStatus;
@@ -79,6 +83,7 @@ class StaffCertificate extends Equatable {
issuer, issuer,
certificateNumber, certificateNumber,
validationStatus, validationStatus,
verificationId,
createdAt, createdAt,
updatedAt, updatedAt,
]; ];
@@ -97,6 +102,7 @@ class StaffCertificate extends Equatable {
String? issuer, String? issuer,
String? certificateNumber, String? certificateNumber,
StaffCertificateValidationStatus? validationStatus, StaffCertificateValidationStatus? validationStatus,
String? verificationId,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
}) { }) {
@@ -113,6 +119,7 @@ class StaffCertificate extends Equatable {
issuer: issuer ?? this.issuer, issuer: issuer ?? this.issuer,
certificateNumber: certificateNumber ?? this.certificateNumber, certificateNumber: certificateNumber ?? this.certificateNumber,
validationStatus: validationStatus ?? this.validationStatus, validationStatus: validationStatus ?? this.validationStatus,
verificationId: verificationId ?? this.verificationId,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
); );

View File

@@ -49,14 +49,22 @@ class CertificatesRepositoryImpl implements CertificatesRepository {
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri); await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
// 3. Initiate verification // 3. Initiate verification
// 3. Initiate verification final List<domain.StaffCertificate> allCerts = await getCertificates();
final domain.StaffCertificate currentCert = allCerts.firstWhere(
(domain.StaffCertificate c) => c.certificationType == certificationType,
);
final String staffId = await _service.getStaffId(); final String staffId = await _service.getStaffId();
await _verificationService.createVerification( final VerificationResponse verificationRes = await _verificationService
.createVerification(
fileUri: uploadRes.fileUri, fileUri: uploadRes.fileUri,
type: certificationType.value, type: certificationType.value,
category: 'CERTIFICATE', category: 'certification',
subjectType: 'STAFF', subjectType: 'worker',
subjectId: staffId, subjectId: staffId,
rules: <String, dynamic>{
'certificateDescription': currentCert.description,
},
); );
// 4. Update/Create Certificate in Data Connect // 4. Update/Create Certificate in Data Connect
@@ -70,6 +78,7 @@ class CertificatesRepositoryImpl implements CertificatesRepository {
certificateNumber: certificateNumber, certificateNumber: certificateNumber,
validationStatus: validationStatus:
domain.StaffCertificateValidationStatus.pendingExpertReview, domain.StaffCertificateValidationStatus.pendingExpertReview,
verificationId: verificationRes.verificationId,
); );
// 5. Return updated list or the specific certificate // 5. Return updated list or the specific certificate

View File

@@ -44,6 +44,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
_numberController.text = widget.certificate!.certificateNumber ?? ''; _numberController.text = widget.certificate!.certificateNumber ?? '';
_nameController.text = widget.certificate!.name; _nameController.text = widget.certificate!.name;
_selectedType = widget.certificate!.certificationType; _selectedType = widget.certificate!.certificationType;
_selectedFilePath = widget.certificate?.certificateUrl;
} else { } else {
_selectedType = ComplianceType.other; _selectedType = ComplianceType.other;
} }
@@ -116,6 +117,8 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
} }
Future<void> _showRemoveConfirmation(BuildContext context) async { Future<void> _showRemoveConfirmation(BuildContext context) async {
final CertificateUploadCubit cubit =
BlocProvider.of<CertificateUploadCubit>(context);
final bool? confirmed = await showDialog<bool>( final bool? confirmed = await showDialog<bool>(
context: context, context: context,
builder: (BuildContext context) => AlertDialog( builder: (BuildContext context) => AlertDialog(
@@ -136,9 +139,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
); );
if (confirmed == true && mounted) { if (confirmed == true && mounted) {
BlocProvider.of<CertificateUploadCubit>( await cubit.deleteCertificate(widget.certificate!.certificationType);
context,
).deleteCertificate(widget.certificate!.certificationType);
} }
} }
@@ -189,6 +190,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
TextField( TextField(
controller: _nameController, controller: _nameController,
enabled: false,
decoration: InputDecoration( decoration: InputDecoration(
hintText: t.staff_certificates.upload_modal.name_hint, hintText: t.staff_certificates.upload_modal.name_hint,
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -198,6 +200,46 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
), ),
const SizedBox(height: UiConstants.space4), 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 // Expiry Date Field
Text( Text(
t.staff_certificates.upload_modal.expiry_label, t.staff_certificates.upload_modal.expiry_label,
@@ -239,40 +281,6 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
), ),
const SizedBox(height: UiConstants.space4), 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 // File Selector
Text( Text(
t.staff_certificates.upload_modal.upload_file, t.staff_certificates.upload_modal.upload_file,
@@ -283,29 +291,6 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
selectedFilePath: _selectedFilePath, selectedFilePath: _selectedFilePath,
onTap: _pickFile, onTap: _pickFile,
), ),
// Remove Button (only if existing)
if (widget.certificate != null) ...<Widget>[
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<CertificateUploadPage> {
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
spacing: UiConstants.space4,
children: <Widget>[ children: <Widget>[
// Attestation // Attestation
Row( Row(
@@ -334,7 +320,6 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
), ),
], ],
), ),
const SizedBox(height: UiConstants.space4),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
@@ -391,6 +376,30 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
), ),
), ),
), ),
// Remove Button (only if existing)
if (widget.certificate != null) ...<Widget>[
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( child: Container(
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.tagActive,
border: Border.all(color: UiColors.primary), border: Border.all(color: UiColors.primary),
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
), ),
child: Row( child: Row(
children: <Widget>[ children: <Widget>[
const Icon(UiIcons.file, color: UiColors.primary), const Icon(UiIcons.certificate, color: UiColors.primary),
const SizedBox(width: UiConstants.space3), const SizedBox(width: UiConstants.space3),
Expanded( Expanded(
child: Text( child: Text(
selectedFilePath!.split('/').last, selectedFilePath!.split('/').last,
style: UiTypography.body1m.textPrimary, style: UiTypography.body1m.primary,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),

View File

@@ -451,7 +451,7 @@ query vaidateDayStaffApplication(
$limit: Int $limit: Int
$dayStart: Timestamp $dayStart: Timestamp
$dayEnd: 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( applications(
where: { where: {
staffId: { eq: $staffId } staffId: { eq: $staffId }

View File

@@ -10,7 +10,7 @@ mutation CreateCertificate(
$staffId: UUID! $staffId: UUID!
$validationStatus: ValidationStatus $validationStatus: ValidationStatus
$certificateNumber: String $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( certificate_insert(
data: { data: {
name: $name name: $name
@@ -40,7 +40,7 @@ mutation UpdateCertificate(
$issuer: String $issuer: String
$validationStatus: ValidationStatus $validationStatus: ValidationStatus
$certificateNumber: String $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( certificate_update(
key: { staffId: $staffId, certificationType: $certificationType } key: { staffId: $staffId, certificationType: $certificationType }
data: { data: {
@@ -58,7 +58,7 @@ mutation UpdateCertificate(
} }
mutation DeleteCertificate($staffId: UUID!, $certificationType: ComplianceType!) 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( certificate_delete(
key: { staffId: $staffId, certificationType: $certificationType } key: { staffId: $staffId, certificationType: $certificationType }
) )
@@ -88,7 +88,8 @@ mutation upsertStaffCertificate(
$issuer: String $issuer: String
$certificateNumber: String $certificateNumber: String
$validationStatus: ValidationStatus $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( certificate_upsert(
data: { data: {
staffId: $staffId staffId: $staffId
@@ -100,6 +101,7 @@ mutation upsertStaffCertificate(
issuer: $issuer issuer: $issuer
certificateNumber: $certificateNumber certificateNumber: $certificateNumber
validationStatus: $validationStatus validationStatus: $validationStatus
verificationId: $verificationId
} }
) )
} }

View File

@@ -44,6 +44,7 @@ type Certificate @table(name: "certificates", key: ["staffId", "certificationTyp
certificateNumber: String certificateNumber: String
validationStatus: ValidationStatus validationStatus: ValidationStatus
verificationId: String
staffId: UUID! staffId: UUID!
staff: Staff! @ref(fields: "staffId", references: "id") staff: Staff! @ref(fields: "staffId", references: "id")