feat: Implement comprehensive staff document management with verification status tracking and complete document listing.

This commit is contained in:
Achintha Isuru
2026-02-26 22:23:27 -05:00
parent c113b836f2
commit 5ab5182c1b
10 changed files with 355 additions and 151 deletions

View File

@@ -1,4 +1,5 @@
import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart' as domain; import 'package:krow_domain/krow_domain.dart' as domain;
import '../../domain/repositories/staff_connector_repository.dart'; import '../../domain/repositories/staff_connector_repository.dart';
@@ -349,4 +350,178 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
throw Exception('Error signing out: ${e.toString()}'); throw Exception('Error signing out: ${e.toString()}');
} }
} }
@override
Future<List<domain.StaffDocument>> getStaffDocuments() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final List<QueryResult<Object, Object?>> results =
await Future.wait<QueryResult<Object, Object?>>(
<Future<QueryResult<Object, Object?>>>[
_service.connector.listDocuments().execute(),
_service.connector
.listStaffDocumentsByStaffId(staffId: staffId)
.execute(),
],
);
final QueryResult<dc.ListDocumentsData, void> documentsRes =
results[0] as QueryResult<dc.ListDocumentsData, void>;
final QueryResult<
dc.ListStaffDocumentsByStaffIdData,
dc.ListStaffDocumentsByStaffIdVariables
>
staffDocsRes =
results[1]
as QueryResult<
dc.ListStaffDocumentsByStaffIdData,
dc.ListStaffDocumentsByStaffIdVariables
>;
final List<dc.ListStaffDocumentsByStaffIdStaffDocuments> staffDocs =
staffDocsRes.data.staffDocuments;
return documentsRes.data.documents.map((dc.ListDocumentsDocuments doc) {
// Find if this staff member has already uploaded this document
final dc.ListStaffDocumentsByStaffIdStaffDocuments? currentDoc =
staffDocs
.where(
(dc.ListStaffDocumentsByStaffIdStaffDocuments d) =>
d.documentId == doc.id,
)
.firstOrNull;
return domain.StaffDocument(
id: currentDoc?.id ?? '',
staffId: staffId,
documentId: doc.id,
name: doc.name,
description: doc.description,
status: currentDoc != null
? _mapDocumentStatus(currentDoc.status)
: domain.DocumentStatus.missing,
documentUrl: currentDoc?.documentUrl,
expiryDate: currentDoc?.expiryDate == null
? null
: DateTimeUtils.toDeviceTime(
currentDoc!.expiryDate!.toDateTime(),
),
verificationId: currentDoc?.verificationId,
verificationStatus: currentDoc != null
? _mapFromDCDocumentVerificationStatus(currentDoc.status)
: null,
);
}).toList();
});
}
@override
Future<void> upsertStaffDocument({
required String documentId,
required String documentUrl,
domain.DocumentStatus? status,
String? verificationId,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
final domain.Staff staff = await getStaffProfile();
await _service.connector
.upsertStaffDocument(
staffId: staffId,
staffName: staff.name,
documentId: documentId,
status: _mapToDCDocumentStatus(status),
)
.documentUrl(documentUrl)
.verificationId(verificationId)
.execute();
});
}
domain.DocumentStatus _mapDocumentStatus(
dc.EnumValue<dc.DocumentStatus> status,
) {
if (status is dc.Unknown) {
return domain.DocumentStatus.pending;
}
final dc.DocumentStatus value =
(status as dc.Known<dc.DocumentStatus>).value;
switch (value) {
case dc.DocumentStatus.VERIFIED:
return domain.DocumentStatus.verified;
case dc.DocumentStatus.PENDING:
return domain.DocumentStatus.pending;
case dc.DocumentStatus.MISSING:
return domain.DocumentStatus.missing;
case dc.DocumentStatus.UPLOADED:
case dc.DocumentStatus.EXPIRING:
return domain.DocumentStatus.pending;
case dc.DocumentStatus.PROCESSING:
case dc.DocumentStatus.AUTO_PASS:
case dc.DocumentStatus.AUTO_FAIL:
case dc.DocumentStatus.NEEDS_REVIEW:
case dc.DocumentStatus.APPROVED:
case dc.DocumentStatus.REJECTED:
case dc.DocumentStatus.ERROR:
if (value == dc.DocumentStatus.AUTO_PASS ||
value == dc.DocumentStatus.APPROVED) {
return domain.DocumentStatus.verified;
}
if (value == dc.DocumentStatus.AUTO_FAIL ||
value == dc.DocumentStatus.REJECTED ||
value == dc.DocumentStatus.ERROR) {
return domain.DocumentStatus.rejected;
}
return domain.DocumentStatus.pending;
}
}
domain.DocumentVerificationStatus _mapFromDCDocumentVerificationStatus(
dc.EnumValue<dc.DocumentStatus> status,
) {
if (status is dc.Unknown) {
return domain.DocumentVerificationStatus.error;
}
final String name = (status as dc.Known<dc.DocumentStatus>).value.name;
switch (name) {
case 'PENDING':
return domain.DocumentVerificationStatus.pending;
case 'PROCESSING':
return domain.DocumentVerificationStatus.processing;
case 'AUTO_PASS':
return domain.DocumentVerificationStatus.autoPass;
case 'AUTO_FAIL':
return domain.DocumentVerificationStatus.autoFail;
case 'NEEDS_REVIEW':
return domain.DocumentVerificationStatus.needsReview;
case 'APPROVED':
return domain.DocumentVerificationStatus.approved;
case 'REJECTED':
return domain.DocumentVerificationStatus.rejected;
case 'VERIFIED':
return domain.DocumentVerificationStatus.approved;
case 'ERROR':
return domain.DocumentVerificationStatus.error;
default:
return domain.DocumentVerificationStatus.error;
}
}
dc.DocumentStatus _mapToDCDocumentStatus(domain.DocumentStatus? status) {
if (status == null) return dc.DocumentStatus.PENDING;
switch (status) {
case domain.DocumentStatus.verified:
return dc.DocumentStatus.VERIFIED;
case domain.DocumentStatus.pending:
return dc.DocumentStatus.PENDING;
case domain.DocumentStatus.missing:
return dc.DocumentStatus.MISSING;
case domain.DocumentStatus.rejected:
return dc.DocumentStatus.REJECTED;
case domain.DocumentStatus.expired:
return dc.DocumentStatus.EXPIRING;
}
}
} }

View File

@@ -72,4 +72,15 @@ abstract interface class StaffConnectorRepository {
String? bio, String? bio,
String? profilePictureUrl, String? profilePictureUrl,
}); });
/// Fetches the staff documents for the current authenticated user.
Future<List<StaffDocument>> getStaffDocuments();
/// Upserts staff document information.
Future<void> upsertStaffDocument({
required String documentId,
required String documentUrl,
DocumentStatus? status,
String? verificationId,
});
} }

View File

@@ -77,6 +77,7 @@ export 'src/adapters/financial/bank_account/bank_account_adapter.dart';
// Profile // Profile
export 'src/entities/profile/staff_document.dart'; export 'src/entities/profile/staff_document.dart';
export 'src/entities/profile/document_verification_status.dart';
export 'src/entities/profile/attire_item.dart'; export 'src/entities/profile/attire_item.dart';
export 'src/entities/profile/attire_verification_status.dart'; export 'src/entities/profile/attire_verification_status.dart';
export 'src/entities/profile/relationship_type.dart'; export 'src/entities/profile/relationship_type.dart';

View File

@@ -0,0 +1,39 @@
/// Represents the verification status of a compliance document.
enum DocumentVerificationStatus {
/// Job is created and waiting to be processed.
pending('PENDING'),
/// Job is currently being processed by machine or human.
processing('PROCESSING'),
/// Machine verification passed automatically.
autoPass('AUTO_PASS'),
/// Machine verification failed automatically.
autoFail('AUTO_FAIL'),
/// Machine results are inconclusive and require human review.
needsReview('NEEDS_REVIEW'),
/// Human reviewer approved the verification.
approved('APPROVED'),
/// Human reviewer rejected the verification.
rejected('REJECTED'),
/// An error occurred during processing.
error('ERROR');
const DocumentVerificationStatus(this.value);
/// The string value expected by the Core API.
final String value;
/// Creates a [DocumentVerificationStatus] from a string.
static DocumentVerificationStatus fromString(String value) {
return DocumentVerificationStatus.values.firstWhere(
(DocumentVerificationStatus e) => e.value == value,
orElse: () => DocumentVerificationStatus.error,
);
}
}

View File

@@ -1,17 +1,12 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'document_verification_status.dart';
/// Status of a compliance document. /// Status of a compliance document.
enum DocumentStatus { enum DocumentStatus { verified, pending, missing, rejected, expired }
verified,
pending,
missing,
rejected,
expired
}
/// Represents a staff compliance document. /// Represents a staff compliance document.
class StaffDocument extends Equatable { class StaffDocument extends Equatable {
const StaffDocument({ const StaffDocument({
required this.id, required this.id,
required this.staffId, required this.staffId,
@@ -21,7 +16,10 @@ class StaffDocument extends Equatable {
required this.status, required this.status,
this.documentUrl, this.documentUrl,
this.expiryDate, this.expiryDate,
this.verificationId,
this.verificationStatus,
}); });
/// The unique identifier of the staff document record. /// The unique identifier of the staff document record.
final String id; final String id;
@@ -46,15 +44,23 @@ class StaffDocument extends Equatable {
/// The expiry date of the document. /// The expiry date of the document.
final DateTime? expiryDate; final DateTime? expiryDate;
/// The ID of the verification record.
final String? verificationId;
/// The detailed verification status.
final DocumentVerificationStatus? verificationStatus;
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
id, id,
staffId, staffId,
documentId, documentId,
name, name,
description, description,
status, status,
documentUrl, documentUrl,
expiryDate, expiryDate,
]; verificationId,
verificationStatus,
];
} }

View File

@@ -87,75 +87,51 @@ To ensure a consistent experience across all compliance uploads (documents, cert
--- ---
## 4. Pending — Data Layer (Real Repository Implementation) ### ✅ Completed — Data Layer
> The `DocumentsRepositoryImpl.uploadDocument` method is currently a **mock**. Replace it with the following sequence: #### Data Connect Integration
- `StaffConnectorRepository` interface updated with `getStaffDocuments()` and `upsertStaffDocument()`.
### Step-by-step Repository Implementation - `upsertStaffDocument` mutation in `backend/dataconnect/connector/staffDocument/mutations.gql` updated to accept `verificationId`.
- `getStaffDocumentByKey` and `listStaffDocumentsByStaffId` queries updated to include `verificationId`.
```dart - SDK regenerated: `make dataconnect-generate-sdk ENV=dev`.
@override
Future<domain.StaffDocument> uploadDocument( #### Repository Implementation — `StaffConnectorRepositoryImpl`
String documentId, - **`getStaffDocuments()`**:
String filePath, - Uses `Future.wait` to simultaneously fetch the full list of available `Document` types and the current staff's `StaffDocument` records.
) async { - Maps the master list of `Document` entities, joining them with any existing `StaffDocument` entry.
return _service.execute(() async { - This ensures the UI always shows all required documents, even if they haven't been uploaded yet (status: `missing`).
// 1. Upload file to cloud storage - Populates `verificationId` and `verificationStatus` for presentation layer mapping.
final String fileUri = await _fileUploadService.upload(filePath); - **`upsertStaffDocument()`**:
- Handles the upsert (create or update) of a staff document record.
// 2. Generate a signed URL for viewing - Explicitly passes `documentUrl` and `verificationId`, ensuring metadata is persisted alongside the file reference.
final String documentUrl = - **Status Mapping**:
await _signedUrlService.createSignedUrl(fileUri); - `_mapDocumentStatus`: Collapses backend statuses into domain `verified | pending | rejected | missing`.
- `_mapFromDCDocumentVerificationStatus`: Preserves the full granularity of the backend status for the UI/presentation layer.
// 3. Initiate verification job
final String verificationId = await _verificationService #### Feature Repository — `DocumentsRepositoryImpl`
.createVerification(fileUri: fileUri, documentType: documentId); - Fixed to ensure all cross-cutting services (`FileUploadService`, `VerificationService`) are properly orchestrated.
- **Verification Integration**: When a document is uploaded, a verification job is triggered, and its `verificationId` is saved to Data Connect immediately. This allows the UI to show a "Processing" state while the background job runs.
// 4. (Optional) Poll for immediate feedback (~10s)
// final VerificationStatus status =
// await _verificationService.poll(verificationId, maxSeconds: 10);
// 5. Persist to Data Connect
final String staffId = await _service.getStaffId();
await _service.connector.upsertStaffDocument(
staffId: staffId,
documentId: documentId,
documentUrl: documentUrl,
verificationId: verificationId,
status: DocumentStatus.UPLOADED,
);
return domain.StaffDocument(
id: documentId,
staffId: staffId,
documentId: documentId,
name: '', // populated from local cache or refetch
status: domain.DocumentStatus.pending,
documentUrl: documentUrl,
);
});
}
```
--- ---
## 5. DocumentStatus Mapping ## 5. DocumentStatus Mapping Reference
The backend uses a richer enum than the domain layer. The mapping in `_mapDocumentStatus` should be finalized as follows: The backend uses a richer enum than the domain layer. The mapping is standardized as follows:
| Backend `DocumentStatus` | Domain `DocumentStatus` | Notes | | Backend `DocumentStatus` | Domain `DocumentStatus` | Notes |
|--------------------------|-------------------------|-------| |--------------------------|-------------------------|-------|
| `VERIFIED` | `verified` | Fully approved | | `VERIFIED` | `verified` | Fully approved |
| `AUTO_PASS` | `verified` | AI approved |
| `APPROVED` | `verified` | Manually approved |
| `UPLOADED` | `pending` | File received, not yet processed | | `UPLOADED` | `pending` | File received, not yet processed |
| `PENDING` | `pending` | Queued for verification | | `PENDING` | `pending` | Queued for verification |
| `PROCESSING` | `pending` | AI analysis in progress | | `PROCESSING` | `pending` | AI analysis in progress |
| `AUTO_PASS` | `verified` | AI approved |
| `APPROVED` | `verified` | Manually approved |
| `NEEDS_REVIEW` | `pending` | AI unsure, human review needed | | `NEEDS_REVIEW` | `pending` | AI unsure, human review needed |
| `EXPIRING` | `pending` | Approaching expiry (treated as pending for renewal) |
| `MISSING` | `missing` | Document not yet uploaded |
| `AUTO_FAIL` | `rejected` | AI rejected | | `AUTO_FAIL` | `rejected` | AI rejected |
| `REJECTED` | `rejected` | Manually rejected; check `rejectionReason` | | `REJECTED` | `rejected` | Manually rejected |
| `ERROR` | `rejected` | System error during verification | | `ERROR` | `rejected` | System error during verification |
| `EXPIRING` | `verified` | Valid but approaching expiry |
--- ---
@@ -172,3 +148,13 @@ DocumentUploadStatus
**Cubit guards:** **Cubit guards:**
- Upload is blocked unless `state.isAttested == true` - Upload is blocked unless `state.isAttested == true`
- Button is only enabled when both a file is selected AND attestation is checked - Button is only enabled when both a file is selected AND attestation is checked
---
## 7. Future Considerations: Certificates
The implementation of the **Certificates** module should follow this exact pattern with a few key differences:
1. **Repository**: Use `StaffConnectorRepository.getStaffCertificates()` and `upsertStaffCertificate()`.
2. **Metadata**: Certificates often require an `expiryDate` and `issueingBody` which should be captured during the upload step if not already present in the schema.
3. **Verification**: If using the same `VerificationService`, ensure the `category` is set to `CERTIFICATE` instead of `DOCUMENT` to trigger appropriate verification logic.
4. **UI**: Mirror the `DocumentUploadPage` design but update the instructions and translation keys to reference certificates.

View File

@@ -7,40 +7,23 @@ import '../../domain/repositories/documents_repository.dart';
/// Implementation of [DocumentsRepository] using Data Connect. /// Implementation of [DocumentsRepository] using Data Connect.
class DocumentsRepositoryImpl implements DocumentsRepository { class DocumentsRepositoryImpl implements DocumentsRepository {
DocumentsRepositoryImpl() : _service = DataConnectService.instance; DocumentsRepositoryImpl({
required FileUploadService uploadService,
required SignedUrlService signedUrlService,
required VerificationService verificationService,
}) : _service = DataConnectService.instance,
_uploadService = uploadService,
_signedUrlService = signedUrlService,
_verificationService = verificationService;
final DataConnectService _service; final DataConnectService _service;
final FileUploadService _uploadService;
final SignedUrlService _signedUrlService;
final VerificationService _verificationService;
@override @override
Future<List<domain.StaffDocument>> getDocuments() async { Future<List<domain.StaffDocument>> getDocuments() async {
return _service.run(() async { return _service.getStaffRepository().getStaffDocuments();
final String staffId = await _service.getStaffId();
/// MOCK IMPLEMENTATION
/// To be replaced with real data connect query when available
return <domain.StaffDocument>[
domain.StaffDocument(
id: 'doc1',
staffId: staffId,
documentId: 'd1',
name: 'Work Permit',
description: 'Valid work permit document',
status: domain.DocumentStatus.verified,
documentUrl: 'https://example.com/documents/work_permit.pdf',
expiryDate: DateTime.now().add(const Duration(days: 365)),
),
domain.StaffDocument(
id: 'doc2',
staffId: staffId,
documentId: 'd2',
name: 'Health and Safety Training',
description:
'Certificate of completion for health and safety training',
status: domain.DocumentStatus.pending,
documentUrl: 'https://example.com/documents/health_safety.pdf',
expiryDate: DateTime.now().add(const Duration(days: 180)),
),
];
});
} }
@override @override
@@ -48,70 +31,63 @@ class DocumentsRepositoryImpl implements DocumentsRepository {
String documentId, String documentId,
String filePath, String filePath,
) async { ) async {
// Mock upload delay return _service.run(() async {
await Future.delayed(const Duration(seconds: 2)); // 1. Upload the file to cloud storage
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
fileName: 'staff_document_${DateTime.now().millisecondsSinceEpoch}.pdf',
visibility: domain.FileVisibility.private,
);
final String staffId = await _service.getStaffId(); // 2. Generate a signed URL for verification service to access the file
return domain.StaffDocument( final SignedUrlResponse signedUrlRes = await _signedUrlService
id: 'mock_uploaded_${DateTime.now().millisecondsSinceEpoch}', .createSignedUrl(fileUri: uploadRes.fileUri);
staffId: staffId,
documentId: documentId,
name: 'Uploaded Document',
status: domain.DocumentStatus.pending,
documentUrl: 'https://example.com/mock.pdf',
);
}
domain.StaffDocument _mapToDomain( // 3. Initiate verification
ListStaffDocumentsByStaffIdStaffDocuments doc, final String staffId = await _service.getStaffId();
) { final VerificationResponse verificationRes = await _verificationService
return domain.StaffDocument( .createVerification(
id: doc.id, fileUri: uploadRes.fileUri,
staffId: doc.staffId, type: documentId, // Assuming documentId aligns with type
documentId: doc.documentId, subjectType: 'STAFF',
name: doc.document.name, subjectId: staffId,
description: null, // Description not available in data source );
status: _mapStatus(doc.status),
documentUrl: doc.documentUrl, // 4. Update/Create StaffDocument in Data Connect
expiryDate: doc.expiryDate == null await _service.getStaffRepository().upsertStaffDocument(
? null documentId: documentId,
: DateTimeUtils.toDeviceTime(doc.expiryDate!.toDateTime()), documentUrl: uploadRes.fileUri,
); status: domain.DocumentStatus.pending,
verificationId: verificationRes.verificationId,
);
// 5. Return the updated document state
final List<domain.StaffDocument> documents = await getDocuments();
return documents.firstWhere(
(domain.StaffDocument d) => d.documentId == documentId,
);
});
} }
domain.DocumentStatus _mapStatus(EnumValue<DocumentStatus> status) { domain.DocumentStatus _mapStatus(EnumValue<DocumentStatus> status) {
if (status is Known<DocumentStatus>) { if (status is Known<DocumentStatus>) {
switch (status.value) { switch (status.value) {
case DocumentStatus.VERIFIED: case DocumentStatus.VERIFIED:
case DocumentStatus.AUTO_PASS:
case DocumentStatus.APPROVED:
return domain.DocumentStatus.verified; return domain.DocumentStatus.verified;
case DocumentStatus.PENDING: case DocumentStatus.PENDING:
case DocumentStatus.UPLOADED:
case DocumentStatus.PROCESSING:
case DocumentStatus.NEEDS_REVIEW:
case DocumentStatus.EXPIRING:
return domain.DocumentStatus.pending; return domain.DocumentStatus.pending;
case DocumentStatus.MISSING: case DocumentStatus.MISSING:
return domain.DocumentStatus.missing; return domain.DocumentStatus.missing;
case DocumentStatus.UPLOADED:
case DocumentStatus.EXPIRING:
return domain.DocumentStatus.pending;
case DocumentStatus.PROCESSING:
// TODO: Handle this case.
throw UnimplementedError();
case DocumentStatus.AUTO_PASS:
// TODO: Handle this case.
throw UnimplementedError();
case DocumentStatus.AUTO_FAIL: case DocumentStatus.AUTO_FAIL:
// TODO: Handle this case.
throw UnimplementedError();
case DocumentStatus.NEEDS_REVIEW:
// TODO: Handle this case.
throw UnimplementedError();
case DocumentStatus.APPROVED:
// TODO: Handle this case.
throw UnimplementedError();
case DocumentStatus.REJECTED: case DocumentStatus.REJECTED:
// TODO: Handle this case.
throw UnimplementedError();
case DocumentStatus.ERROR: case DocumentStatus.ERROR:
// TODO: Handle this case. return domain.DocumentStatus.rejected;
throw UnimplementedError();
} }
} }
// Default to pending for Unknown or unhandled cases // Default to pending for Unknown or unhandled cases

View File

@@ -13,7 +13,13 @@ import 'presentation/pages/document_upload_page.dart';
class StaffDocumentsModule extends Module { class StaffDocumentsModule extends Module {
@override @override
void binds(Injector i) { void binds(Injector i) {
i.addLazySingleton<DocumentsRepository>(DocumentsRepositoryImpl.new); i.addLazySingleton<DocumentsRepository>(
() => DocumentsRepositoryImpl(
uploadService: i.get<FileUploadService>(),
signedUrlService: i.get<SignedUrlService>(),
verificationService: i.get<VerificationService>(),
),
);
i.addLazySingleton(GetDocumentsUseCase.new); i.addLazySingleton(GetDocumentsUseCase.new);
i.addLazySingleton(UploadDocumentUseCase.new); i.addLazySingleton(UploadDocumentUseCase.new);

View File

@@ -58,6 +58,7 @@ mutation upsertStaffDocument(
$status: DocumentStatus! $status: DocumentStatus!
$documentUrl: String $documentUrl: String
$expiryDate: Timestamp $expiryDate: Timestamp
$verificationId: String
) @auth(level: USER) { ) @auth(level: USER) {
staffDocument_upsert( staffDocument_upsert(
data: { data: {
@@ -67,6 +68,7 @@ mutation upsertStaffDocument(
status: $status status: $status
documentUrl: $documentUrl documentUrl: $documentUrl
expiryDate: $expiryDate expiryDate: $expiryDate
verificationId: $verificationId
} }
) )
} }

View File

@@ -11,6 +11,7 @@ query getStaffDocumentByKey(
status status
documentUrl documentUrl
expiryDate expiryDate
verificationId
createdAt createdAt
updatedAt updatedAt
document { document {
@@ -39,6 +40,7 @@ query listStaffDocumentsByStaffId(
status status
documentUrl documentUrl
expiryDate expiryDate
verificationId
createdAt createdAt
updatedAt updatedAt
document { document {