feat: Implement attire photo capture, update AttireItem entity, and streamline the photo upload and state management flow.

This commit is contained in:
Achintha Isuru
2026-02-25 13:56:35 -05:00
parent 0ad70a4a42
commit 9c9cdaca78
19 changed files with 475 additions and 54 deletions

View File

@@ -5,23 +5,23 @@ import 'package:design_system/design_system.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:marionette_flutter/marionette_flutter.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krowwithus_staff/firebase_options.dart'; import 'package:krowwithus_staff/firebase_options.dart';
import 'package:marionette_flutter/marionette_flutter.dart';
import 'package:staff_authentication/staff_authentication.dart' import 'package:staff_authentication/staff_authentication.dart'
as staff_authentication; as staff_authentication;
import 'package:staff_main/staff_main.dart' as staff_main; import 'package:staff_main/staff_main.dart' as staff_main;
import 'package:krow_core/core.dart';
import 'package:image_picker/image_picker.dart';
import 'src/widgets/session_listener.dart'; import 'src/widgets/session_listener.dart';
void main() async { void main() async {
final bool isFlutterTest = final bool isFlutterTest = !kIsWeb
!kIsWeb ? Platform.environment.containsKey('FLUTTER_TEST') : false; ? Platform.environment.containsKey('FLUTTER_TEST')
: false;
if (kDebugMode && !isFlutterTest) { if (kDebugMode && !isFlutterTest) {
MarionetteBinding.ensureInitialized( MarionetteBinding.ensureInitialized(
MarionetteConfiguration( MarionetteConfiguration(
@@ -63,20 +63,9 @@ void main() async {
/// The main application module. /// The main application module.
class AppModule extends Module { class AppModule extends Module {
@override
void binds(Injector i) {
i.addLazySingleton<ImagePicker>(ImagePicker.new);
i.addLazySingleton<CameraService>(
() => CameraService(i.get<ImagePicker>()),
);
i.addLazySingleton<GalleryService>(
() => GalleryService(i.get<ImagePicker>()),
);
i.addLazySingleton<FilePickerService>(FilePickerService.new);
}
@override @override
List<Module> get imports => <Module>[ List<Module> get imports => <Module>[
CoreModule(),
core_localization.LocalizationModule(), core_localization.LocalizationModule(),
staff_authentication.StaffAuthenticationModule(), staff_authentication.StaffAuthenticationModule(),
]; ];

View File

@@ -1,5 +1,7 @@
library; library;
export 'src/core_module.dart';
export 'src/domain/arguments/usecase_argument.dart'; export 'src/domain/arguments/usecase_argument.dart';
export 'src/domain/usecases/usecase.dart'; export 'src/domain/usecases/usecase.dart';
export 'src/utils/date_time_utils.dart'; export 'src/utils/date_time_utils.dart';

View File

@@ -0,0 +1,48 @@
import 'package:dio/dio.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:image_picker/image_picker.dart';
import 'package:krow_domain/krow_domain.dart';
import '../core.dart';
/// A module that provides core services and shared dependencies.
///
/// This module should be imported by the root [AppModule] to make
/// core services available globally as singletons.
class CoreModule extends Module {
@override
void exportedBinds(Injector i) {
// 1. Register the base HTTP client
i.addSingleton<Dio>(() => Dio());
// 2. Register the base API service
i.addSingleton<BaseApiService>(() => ApiService(i.get<Dio>()));
// 3. Register Core API Services (Orchestrators)
i.addSingleton<FileUploadService>(
() => FileUploadService(i.get<BaseApiService>()),
);
i.addSingleton<SignedUrlService>(
() => SignedUrlService(i.get<BaseApiService>()),
);
i.addSingleton<VerificationService>(
() => VerificationService(i.get<BaseApiService>()),
);
i.addSingleton<LlmService>(() => LlmService(i.get<BaseApiService>()));
// 4. Register Device dependency
i.addSingleton<ImagePicker>(ImagePicker.new);
// 5. Register Device Services
i.addSingleton<CameraService>(() => CameraService(i.get<ImagePicker>()));
i.addSingleton<GalleryService>(() => GalleryService(i.get<ImagePicker>()));
i.addSingleton<FilePickerService>(FilePickerService.new);
i.addSingleton<DeviceFileUploadService>(
() => DeviceFileUploadService(
cameraService: i.get<CameraService>(),
galleryService: i.get<GalleryService>(),
apiUploadService: i.get<FileUploadService>(),
),
);
}
}

View File

@@ -8,24 +8,26 @@ class CoreApiEndpoints {
static const String baseUrl = AppConfig.coreApiBaseUrl; static const String baseUrl = AppConfig.coreApiBaseUrl;
/// Upload a file. /// Upload a file.
static const String uploadFile = '/core/upload-file'; static const String uploadFile = '$baseUrl/core/upload-file';
/// Create a signed URL for a file. /// Create a signed URL for a file.
static const String createSignedUrl = '/core/create-signed-url'; static const String createSignedUrl = '$baseUrl/core/create-signed-url';
/// Invoke a Large Language Model. /// Invoke a Large Language Model.
static const String invokeLlm = '/core/invoke-llm'; static const String invokeLlm = '$baseUrl/core/invoke-llm';
/// Root for verification operations. /// Root for verification operations.
static const String verifications = '/core/verifications'; static const String verifications = '$baseUrl/core/verifications';
/// Get status of a verification job. /// Get status of a verification job.
static String verificationStatus(String id) => '/core/verifications/$id'; static String verificationStatus(String id) =>
'$baseUrl/core/verifications/$id';
/// Review a verification decision. /// Review a verification decision.
static String verificationReview(String id) => static String verificationReview(String id) =>
'/core/verifications/$id/review'; '$baseUrl/core/verifications/$id/review';
/// Retry a verification job. /// Retry a verification job.
static String verificationRetry(String id) => '/core/verifications/$id/retry'; static String verificationRetry(String id) =>
'$baseUrl/core/verifications/$id/retry';
} }

View File

@@ -229,7 +229,8 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return optionsResponse.data.attireOptions.map((e) { return optionsResponse.data.attireOptions.map((e) {
final GetStaffAttireStaffAttires? userAttire = attireMap[e.id]; final GetStaffAttireStaffAttires? userAttire = attireMap[e.id];
return AttireItem( return AttireItem(
id: e.itemId, id: e.id,
code: e.itemId,
label: e.label, label: e.label,
description: e.description, description: e.description,
imageUrl: e.imageUrl, imageUrl: e.imageUrl,
@@ -238,6 +239,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
userAttire?.verificationStatus?.stringValue, userAttire?.verificationStatus?.stringValue,
), ),
photoUrl: userAttire?.verificationPhotoUrl, photoUrl: userAttire?.verificationPhotoUrl,
verificationId: userAttire?.verificationId,
); );
}).toList(); }).toList();
}); });
@@ -263,7 +265,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
await _service.connector await _service.connector
.upsertStaffAttire(staffId: staffId, attireOptionId: attireOptionId) .upsertStaffAttire(staffId: staffId, attireOptionId: attireOptionId)
.verificationPhotoUrl(photoUrl) .verificationPhotoUrl(photoUrl)
// .verificationId(verificationId) // Uncomment after SDK regeneration .verificationId(verificationId)
.execute(); .execute();
}); });
} }

View File

@@ -9,6 +9,7 @@ class AttireItem extends Equatable {
/// Creates an [AttireItem]. /// Creates an [AttireItem].
const AttireItem({ const AttireItem({
required this.id, required this.id,
required this.code,
required this.label, required this.label,
this.description, this.description,
this.imageUrl, this.imageUrl,
@@ -18,9 +19,12 @@ class AttireItem extends Equatable {
this.verificationId, this.verificationId,
}); });
/// Unique identifier of the attire item. /// Unique identifier of the attire item (UUID).
final String id; final String id;
/// String code for the attire item (e.g. BLACK_TSHIRT).
final String code;
/// Display name of the item. /// Display name of the item.
final String label; final String label;
@@ -45,6 +49,7 @@ class AttireItem extends Equatable {
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
id, id,
code,
label, label,
description, description,
imageUrl, imageUrl,
@@ -53,4 +58,29 @@ class AttireItem extends Equatable {
photoUrl, photoUrl,
verificationId, verificationId,
]; ];
/// Creates a copy of this [AttireItem] with the given fields replaced.
AttireItem copyWith({
String? id,
String? code,
String? label,
String? description,
String? imageUrl,
bool? isMandatory,
AttireVerificationStatus? verificationStatus,
String? photoUrl,
String? verificationId,
}) {
return AttireItem(
id: id ?? this.id,
code: code ?? this.code,
label: label ?? this.label,
description: description ?? this.description,
imageUrl: imageUrl ?? this.imageUrl,
isMandatory: isMandatory ?? this.isMandatory,
verificationStatus: verificationStatus ?? this.verificationStatus,
photoUrl: photoUrl ?? this.photoUrl,
verificationId: verificationId ?? this.verificationId,
);
}
} }

View File

@@ -1,4 +1,5 @@
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:image_picker/image_picker.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:staff_attire/src/presentation/blocs/attire/attire_cubit.dart'; import 'package:staff_attire/src/presentation/blocs/attire/attire_cubit.dart';
import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart'; import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart';
@@ -13,6 +14,14 @@ import 'presentation/pages/attire_page.dart';
class StaffAttireModule extends Module { class StaffAttireModule extends Module {
@override @override
void binds(Injector i) { void binds(Injector i) {
/// third party services
i.addLazySingleton<ImagePicker>(ImagePicker.new);
/// local services
i.addLazySingleton<CameraService>(
() => CameraService(i.get<ImagePicker>()),
);
// Repository // Repository
i.addLazySingleton<AttireRepository>(AttireRepositoryImpl.new); i.addLazySingleton<AttireRepository>(AttireRepositoryImpl.new);

View File

@@ -1,3 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
@@ -31,16 +34,78 @@ class AttireRepositoryImpl implements AttireRepository {
} }
@override @override
Future<String> uploadPhoto(String itemId, String filePath) async { Future<AttireItem> uploadPhoto(String itemId, String filePath) async {
// In a real app, this would upload to Firebase Storage first. // 1. Upload file to Core API
// Since the prototype returns a mock URL, we'll use that to upsert our record. final FileUploadService uploadService = Modular.get<FileUploadService>();
final String mockUrl = 'mock_url_for_$itemId'; final ApiResponse uploadRes = await uploadService.uploadFile(
filePath: filePath,
await _connector.upsertStaffAttire( fileName: filePath.split('/').last,
attireOptionId: itemId,
photoUrl: mockUrl,
); );
return mockUrl; if (!uploadRes.code.startsWith('2')) {
throw Exception('Upload failed: ${uploadRes.message}');
}
final String fileUri = uploadRes.data?['fileUri'] as String;
// 2. Create signed URL for the uploaded file
final SignedUrlService signedUrlService = Modular.get<SignedUrlService>();
final ApiResponse signedUrlRes = await signedUrlService.createSignedUrl(
fileUri: fileUri,
);
final String photoUrl = signedUrlRes.data?['signedUrl'] as String;
// 3. Initiate verification job
final VerificationService verificationService =
Modular.get<VerificationService>();
final Staff staff = await _connector.getStaffProfile();
// Get item details for verification rules
final List<AttireItem> options = await _connector.getAttireOptions();
final AttireItem targetItem = options.firstWhere(
(AttireItem e) => e.id == itemId,
);
final String dressCode =
'${targetItem.description ?? ''} ${targetItem.label}'.trim();
final ApiResponse verifyRes = await verificationService.createVerification(
type: 'attire',
subjectType: 'worker',
subjectId: staff.id,
fileUri: fileUri,
rules: <String, dynamic>{'dressCode': dressCode},
);
final String verificationId = verifyRes.data?['verificationId'] as String;
// 4. Poll for status until it's finished or timeout (max 10 seconds)
try {
int attempts = 0;
bool isFinished = false;
while (!isFinished && attempts < 5) {
await Future<void>.delayed(const Duration(seconds: 2));
final ApiResponse statusRes = await verificationService.getStatus(
verificationId,
);
final String? status = statusRes.data?['status'] as String?;
if (status != null && status != 'PENDING' && status != 'QUEUED') {
isFinished = true;
}
attempts++;
}
} catch (e) {
debugPrint('Polling failed or timed out: $e');
// Continue anyway, as we have the verificationId
}
// 5. Update Data Connect
await _connector.upsertStaffAttire(
attireOptionId: itemId,
photoUrl: photoUrl,
verificationId: verificationId,
);
// 6. Return updated AttireItem by re-fetching to get the PENDING/SUCCESS status
final List<AttireItem> finalOptions = await _connector.getAttireOptions();
return finalOptions.firstWhere((AttireItem e) => e.id == itemId);
} }
} }

View File

@@ -5,7 +5,7 @@ abstract interface class AttireRepository {
Future<List<AttireItem>> getAttireOptions(); Future<List<AttireItem>> getAttireOptions();
/// Uploads a photo for a specific attire item. /// Uploads a photo for a specific attire item.
Future<String> uploadPhoto(String itemId, String filePath); Future<AttireItem> uploadPhoto(String itemId, String filePath);
/// Saves the user's attire selection and attestations. /// Saves the user's attire selection and attestations.
Future<void> saveAttire({ Future<void> saveAttire({

View File

@@ -1,16 +1,17 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../arguments/upload_attire_photo_arguments.dart'; import '../arguments/upload_attire_photo_arguments.dart';
import '../repositories/attire_repository.dart'; import '../repositories/attire_repository.dart';
/// Use case to upload a photo for an attire item. /// Use case to upload a photo for an attire item.
class UploadAttirePhotoUseCase class UploadAttirePhotoUseCase
extends UseCase<UploadAttirePhotoArguments, String> { extends UseCase<UploadAttirePhotoArguments, AttireItem> {
/// Creates a [UploadAttirePhotoUseCase]. /// Creates a [UploadAttirePhotoUseCase].
UploadAttirePhotoUseCase(this._repository); UploadAttirePhotoUseCase(this._repository);
final AttireRepository _repository; final AttireRepository _repository;
@override @override
Future<String> call(UploadAttirePhotoArguments arguments) { Future<AttireItem> call(UploadAttirePhotoArguments arguments) {
return _repository.uploadPhoto(arguments.itemId, arguments.filePath); return _repository.uploadPhoto(arguments.itemId, arguments.filePath);
} }
} }

View File

@@ -64,9 +64,21 @@ class AttireCubit extends Cubit<AttireState>
emit(state.copyWith(selectedIds: currentSelection)); emit(state.copyWith(selectedIds: currentSelection));
} }
void syncCapturedPhoto(String itemId, String url) { void syncCapturedPhoto(AttireItem item) {
// When a photo is captured, we refresh the options to get the updated status from backend // Update the options list with the new item data
loadOptions(); final List<AttireItem> updatedOptions = state.options
.map((AttireItem e) => e.id == item.id ? item : e)
.toList();
// Update the photo URLs map
final Map<String, String> updatedPhotos = Map<String, String>.from(
state.photoUrls,
);
if (item.photoUrl != null) {
updatedPhotos[item.id] = item.photoUrl!;
}
emit(state.copyWith(options: updatedOptions, photoUrls: updatedPhotos));
} }
Future<void> save() async { Future<void> save() async {

View File

@@ -22,7 +22,7 @@ class AttireState extends Equatable {
return options return options
.firstWhere( .firstWhere(
(AttireItem e) => e.id == id, (AttireItem e) => e.id == id,
orElse: () => const AttireItem(id: '', label: ''), orElse: () => const AttireItem(id: '', code: '', label: ''),
) )
.isMandatory; .isMandatory;
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_attire/src/domain/arguments/upload_attire_photo_arguments.dart'; import 'package:staff_attire/src/domain/arguments/upload_attire_photo_arguments.dart';
import 'package:staff_attire/src/domain/usecases/upload_attire_photo_usecase.dart'; import 'package:staff_attire/src/domain/usecases/upload_attire_photo_usecase.dart';
@@ -22,12 +23,16 @@ class AttireCaptureCubit extends Cubit<AttireCaptureState>
await handleError( await handleError(
emit: emit, emit: emit,
action: () async { action: () async {
final String url = await _uploadAttirePhotoUseCase( final AttireItem item = await _uploadAttirePhotoUseCase(
UploadAttirePhotoArguments(itemId: itemId, filePath: filePath), UploadAttirePhotoArguments(itemId: itemId, filePath: filePath),
); );
emit( emit(
state.copyWith(status: AttireCaptureStatus.success, photoUrl: url), state.copyWith(
status: AttireCaptureStatus.success,
photoUrl: item.photoUrl,
updatedItem: item,
),
); );
}, },
onError: (String errorKey) => state.copyWith( onError: (String errorKey) => state.copyWith(

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
enum AttireCaptureStatus { initial, uploading, success, failure } enum AttireCaptureStatus { initial, uploading, success, failure }
@@ -7,24 +8,28 @@ class AttireCaptureState extends Equatable {
this.status = AttireCaptureStatus.initial, this.status = AttireCaptureStatus.initial,
this.isAttested = false, this.isAttested = false,
this.photoUrl, this.photoUrl,
this.updatedItem,
this.errorMessage, this.errorMessage,
}); });
final AttireCaptureStatus status; final AttireCaptureStatus status;
final bool isAttested; final bool isAttested;
final String? photoUrl; final String? photoUrl;
final AttireItem? updatedItem;
final String? errorMessage; final String? errorMessage;
AttireCaptureState copyWith({ AttireCaptureState copyWith({
AttireCaptureStatus? status, AttireCaptureStatus? status,
bool? isAttested, bool? isAttested,
String? photoUrl, String? photoUrl,
AttireItem? updatedItem,
String? errorMessage, String? errorMessage,
}) { }) {
return AttireCaptureState( return AttireCaptureState(
status: status ?? this.status, status: status ?? this.status,
isAttested: isAttested ?? this.isAttested, isAttested: isAttested ?? this.isAttested,
photoUrl: photoUrl ?? this.photoUrl, photoUrl: photoUrl ?? this.photoUrl,
updatedItem: updatedItem ?? this.updatedItem,
errorMessage: errorMessage, errorMessage: errorMessage,
); );
} }
@@ -34,6 +39,7 @@ class AttireCaptureState extends Equatable {
status, status,
isAttested, isAttested,
photoUrl, photoUrl,
updatedItem,
errorMessage, errorMessage,
]; ];
} }

View File

@@ -298,7 +298,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
fullWidth: true, fullWidth: true,
text: 'Submit Image', text: 'Submit Image',
onPressed: () { onPressed: () {
Modular.to.pop(currentPhotoUrl); Modular.to.pop(state.updatedItem);
}, },
), ),
], ],

View File

@@ -113,10 +113,10 @@ class _AttirePageState extends State<AttirePage> {
isUploading: false, isUploading: false,
uploadedPhotoUrl: state.photoUrls[item.id], uploadedPhotoUrl: state.photoUrls[item.id],
onTap: () async { onTap: () async {
final String? resultUrl = final AttireItem? updatedItem =
await Navigator.push<String?>( await Navigator.push<AttireItem?>(
context, context,
MaterialPageRoute<String?>( MaterialPageRoute<AttireItem?>(
builder: (BuildContext ctx) => builder: (BuildContext ctx) =>
AttireCapturePage( AttireCapturePage(
item: item, item: item,
@@ -126,8 +126,8 @@ class _AttirePageState extends State<AttirePage> {
), ),
); );
if (resultUrl != null && mounted) { if (updatedItem != null && mounted) {
cubit.syncCapturedPhoto(item.id, resultUrl); cubit.syncCapturedPhoto(updatedItem);
} }
}, },
), ),

View File

@@ -89,7 +89,9 @@ class AttireItemCard extends StatelessWidget {
UiChip( UiChip(
label: statusText, label: statusText,
size: UiChipSize.xSmall, size: UiChipSize.xSmall,
variant: item.verificationStatus == 'SUCCESS' variant:
item.verificationStatus ==
AttireVerificationStatus.success
? UiChipVariant.primary ? UiChipVariant.primary
: UiChipVariant.secondary, : UiChipVariant.secondary,
), ),
@@ -112,10 +114,12 @@ class AttireItemCard extends StatelessWidget {
) )
else if (hasPhoto && !isUploading) else if (hasPhoto && !isUploading)
Icon( Icon(
item.verificationStatus == 'SUCCESS' item.verificationStatus == AttireVerificationStatus.success
? UiIcons.check ? UiIcons.check
: UiIcons.clock, : UiIcons.clock,
color: item.verificationStatus == 'SUCCESS' color:
item.verificationStatus ==
AttireVerificationStatus.success
? UiColors.textPrimary ? UiColors.textPrimary
: UiColors.textWarning, : UiColors.textWarning,
size: 24, size: 24,

View File

@@ -27,6 +27,7 @@ dependencies:
path: ../../../../../design_system path: ../../../../../design_system
core_localization: core_localization:
path: ../../../../../core_localization path: ../../../../../core_localization
image_picker: ^1.2.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -0,0 +1,245 @@
# M4 Core API Frontend Guide (Dev)
Status: Active
Last updated: 2026-02-24
Audience: Web and mobile frontend developers
## 1) Base URLs (dev)
1. Core API: `https://krow-core-api-e3g6witsvq-uc.a.run.app`
## 2) Auth requirements
1. Send Firebase ID token on protected routes:
```http
Authorization: Bearer <firebase-id-token>
```
2. Health route is public:
- `GET /health`
3. All other routes require Firebase token.
## 3) Standard error envelope
```json
{
"code": "STRING_CODE",
"message": "Human readable message",
"details": {},
"requestId": "uuid"
}
```
## 4) Core API endpoints
## 4.1 Upload file
1. Route: `POST /core/upload-file`
2. Alias: `POST /uploadFile`
3. Content type: `multipart/form-data`
4. Form fields:
- `file` (required)
- `visibility` (optional: `public` or `private`, default `private`)
- `category` (optional)
5. Accepted file types:
- `application/pdf`
- `image/jpeg`
- `image/jpg`
- `image/png`
6. Max upload size: `10 MB` (default)
7. Current behavior: real upload to Cloud Storage (not mock)
8. Success `200` example:
```json
{
"fileUri": "gs://krow-workforce-dev-private/uploads/<uid>/173...",
"contentType": "application/pdf",
"size": 12345,
"bucket": "krow-workforce-dev-private",
"path": "uploads/<uid>/173..._file.pdf",
"requestId": "uuid"
}
```
## 4.2 Create signed URL
1. Route: `POST /core/create-signed-url`
2. Alias: `POST /createSignedUrl`
3. Request body:
```json
{
"fileUri": "gs://krow-workforce-dev-private/uploads/<uid>/file.pdf",
"expiresInSeconds": 300
}
```
4. Security checks:
- bucket must be allowed (`krow-workforce-dev-public` or `krow-workforce-dev-private`)
- path must be owned by caller (`uploads/<caller_uid>/...`)
- object must exist
- `expiresInSeconds` must be `<= 900`
5. Success `200` example:
```json
{
"signedUrl": "https://storage.googleapis.com/...",
"expiresAt": "2026-02-24T15:22:28.105Z",
"requestId": "uuid"
}
```
6. Typical errors:
- `400 VALIDATION_ERROR` (bad payload or expiry too high)
- `403 FORBIDDEN` (path not owned by caller)
- `404 NOT_FOUND` (object does not exist)
## 4.3 Invoke model
1. Route: `POST /core/invoke-llm`
2. Alias: `POST /invokeLLM`
3. Request body:
```json
{
"prompt": "Return JSON with keys summary and risk.",
"responseJsonSchema": {
"type": "object",
"properties": {
"summary": { "type": "string" },
"risk": { "type": "string" }
},
"required": ["summary", "risk"]
},
"fileUrls": []
}
```
4. Current behavior: real Vertex model call (not mock)
- model: `gemini-2.0-flash-001`
- timeout: `20 seconds`
5. Rate limit:
- per-user `20 requests/minute` (default)
- on limit: `429 RATE_LIMITED`
- includes `Retry-After` header
6. Success `200` example:
```json
{
"result": { "summary": "text", "risk": "Low" },
"model": "gemini-2.0-flash-001",
"latencyMs": 367,
"requestId": "uuid"
}
```
## 4.4 Create verification job
1. Route: `POST /core/verifications`
2. Auth: required
3. Purpose: enqueue an async verification job for an uploaded file.
4. Request body:
```json
{
"type": "attire",
"subjectType": "worker",
"subjectId": "<worker-id>",
"fileUri": "gs://krow-workforce-dev-private/uploads/<uid>/file.pdf",
"rules": {
"dressCode": "black shoes"
}
}
```
5. Success `202` example:
```json
{
"verificationId": "ver_123",
"status": "PENDING",
"type": "attire",
"requestId": "uuid"
}
```
6. Current machine processing behavior in dev:
- `attire`: live vision check using Vertex Gemini Flash Lite model.
- `government_id`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured).
- `certification`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured).
## 4.5 Get verification status
1. Route: `GET /core/verifications/{verificationId}`
2. Auth: required
3. Purpose: polling status from frontend.
4. Success `200` example:
```json
{
"verificationId": "ver_123",
"status": "NEEDS_REVIEW",
"type": "attire",
"review": null,
"requestId": "uuid"
}
```
## 4.6 Review verification
1. Route: `POST /core/verifications/{verificationId}/review`
2. Auth: required
3. Purpose: final human decision for the verification.
4. Request body:
```json
{
"decision": "APPROVED",
"note": "Manual review passed",
"reasonCode": "MANUAL_REVIEW"
}
```
5. Success `200` example:
```json
{
"verificationId": "ver_123",
"status": "APPROVED",
"review": {
"decision": "APPROVED",
"reviewedBy": "<uid>"
},
"requestId": "uuid"
}
```
## 4.7 Retry verification
1. Route: `POST /core/verifications/{verificationId}/retry`
2. Auth: required
3. Purpose: requeue verification to run again.
4. Success `202` example: status resets to `PENDING`.
## 5) Frontend fetch examples (web)
## 5.1 Signed URL request
```ts
const token = await firebaseAuth.currentUser?.getIdToken();
const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/create-signed-url', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileUri: 'gs://krow-workforce-dev-private/uploads/<uid>/file.pdf',
expiresInSeconds: 300,
}),
});
const data = await res.json();
```
## 5.2 Model request
```ts
const token = await firebaseAuth.currentUser?.getIdToken();
const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/invoke-llm', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: 'Return JSON with status.',
responseJsonSchema: {
type: 'object',
properties: { status: { type: 'string' } },
required: ['status'],
},
}),
});
const data = await res.json();
```
## 6) Notes for frontend team
1. Use canonical `/core/*` routes for new work.
2. Aliases exist only for migration compatibility.
3. `requestId` in responses should be logged client-side for debugging.
4. For 429 on model route, retry with exponential backoff and respect `Retry-After`.
5. Verification routes are now available in dev under `/core/verifications*`.
6. Current verification processing is async and returns machine statuses first (`PENDING`, `PROCESSING`, `NEEDS_REVIEW`, etc.).
7. Full verification design and policy details:
`docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md`.