Certification Flow
A workflow-driven example: a Certification entity with the Versionable + Draftable capabilities, a draft approval lifecycle, an e-signature gate on approval, version history, and expiration handling.
This is the canonical pattern for any regulated entity in the system — quality records, controlled documents, validated formulations. Wire it once and every action that mutates the record is captured in the audit trail.
Lifecycle
Certification Model
EntityCapability.versionable() adds version_number / is_active; EntityCapability.draftable() enables the draft workflow (Submit / Approve / Reject / Cancel + a DraftMetadata carrier).
@Entity(
schema: 'lms',
identifier: 'certifications',
name: 'Certification',
pluralName: 'Certifications',
description: 'Course completion certifications',
icon: FluentIcons.ribbon_24_regular,
priority: 50,
category: lmsCategory,
menuCategory: lmsMenuCategory,
capabilities: {
EntityCapability.versionable(),
EntityCapability.draftable(),
},
visibility: EntityVisibility.all,
navigation: EntityNavigation(
prefix: '/lms/certifications',
authorize: AuthorizeEntity(
list: AuthorizeRule(Authorize.permission('lms.certifications.view')),
create: AuthorizeRule(Authorize.permission('lms.certifications.approve')),
view: AuthorizeRule(Authorize.permission('lms.certifications.view')),
edit: AuthorizeRule(Authorize.permission('lms.certifications.approve')),
),
),
)
@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false)
class Certification extends $CertificationBase {
@Field(label: 'Course', type: ReferenceFieldType(entityType: 'courses'))
final String courseId;
@Field(
label: 'Participant',
type: ReferenceFieldType(entityType: 'participants'),
)
final String participantId;
@Field(label: 'Status', filterable: true, sortable: true)
final String status;
@Field(label: 'Certificate #', sortable: true)
final String? certificateNumber;
@Field(label: 'Issued', sortable: true)
final DateTime? issuedAt;
@Field(label: 'Expires', sortable: true)
final DateTime? expiresAt;
@JsonKey(name: 'course', includeToJson: false)
final EntityReference? course;
@JsonKey(name: 'participant', includeToJson: false)
final EntityReference? participant;
Certification({
required super.id,
required super.name,
required this.courseId,
required this.participantId,
this.status = 'draft',
this.certificateNumber,
this.issuedAt,
this.expiresAt,
this.course,
this.participant,
super.versionNumber,
super.isActive,
super.draftMetadata,
super.createdAt,
super.updatedAt,
super.createdBy,
super.updatedBy,
});
factory Certification.fromJson(Map<String, dynamic> json) =>
_$CertificationFromJson(json);
@override
Map<String, dynamic> toJson() => _$CertificationToJson(this);
bool get isExpired =>
expiresAt != null && DateTime.now().isAfter(expiresAt!);
bool get canRenew => status == 'expired' || isExpired;
}Certification API
The generator emits $CertificationApiBase with hasDrafts: true (because of EntityCapability.draftable()). Add the lifecycle endpoints:
class CertificationApi extends $CertificationApiBase {
const CertificationApi();
Future<Certification?> submitForReview(String id) =>
_updateStatus(id, 'under_review');
Future<Certification?> approve(
String id, {
required String signatureId,
String? remarks,
}) async {
final res = await vyuh.network.post(
Uri.parse('${pathBuilder.one(id)}/approve'),
headers: defaultHeaders,
body: jsonEncode({
'signature_id': signatureId,
if (remarks != null) 'remarks': remarks,
}),
);
return _maybe(res, id);
}
Future<Certification?> reject(String id, {String? reason}) =>
_updateStatus(id, 'rejected', reason: reason);
Future<Certification?> issue(String id) => _updateStatus(id, 'issued');
Future<Certification?> revoke(String id, {String? reason}) =>
_updateStatus(id, 'revoked', reason: reason);
Future<Certification?> renew(String id, {required DateTime newExpiresAt}) async {
final res = await vyuh.network.post(
Uri.parse('${pathBuilder.one(id)}/renew'),
headers: defaultHeaders,
body: jsonEncode({'expires_at': newExpiresAt.toUtc().toIso8601String()}),
);
return _maybe(res, id);
}
Future<Certification?> _updateStatus(String id, String status,
{String? reason}) async {
final res = await vyuh.network.patch(
Uri.parse('${pathBuilder.one(id)}/status'),
headers: defaultHeaders,
body: jsonEncode({
'status': status,
if (reason != null) 'reason': reason,
}),
);
return _maybe(res, id);
}
Certification? _maybe(http.Response res, String id) {
if (res.statusCode != 200) return null;
final cache = vyuh.entity?.services.queryCache;
cache?.invalidateById('certifications', id);
cache?.invalidateOperation('certifications', 'list');
final json = jsonDecode(res.body) as Map<String, dynamic>;
return Certification.fromJson(json['data'] as Map<String, dynamic>);
}
}
const certificationApi = CertificationApi();Draft Approval Workflow
Because the entity is Draftable, every mutation that flows through POST /certifications, PUT /certifications/:id, etc. produces a draft in under_review, approved, or effective state — the framework reads DraftMetadata.status and surfaces the right banner / tab on the detail view.
DraftStatus values:
enum DraftStatus {
draft, // user is editing
underReview, // submitted for approval
approved, // all approvals complete (intermediate)
effective, // changes applied (terminal)
rejected, // rejected by approver (terminal)
cancelled, // cancelled by user (terminal)
deleted, // deleted (terminal)
failed, // processing failed (terminal)
}Submit for Review
final submitAction = EntityAction<Certification>(
icon: FluentIcons.send_24_regular,
title: 'Submit for Review',
isVisible: (c) async =>
c.draftMetadata?.status == DraftStatus.draft,
authorize: Authorize.permission('lms.certifications.view'),
handler: (context, cert) async {
await certificationApi.submitForReview(cert.id);
},
);Approve with E-Signature
The approval path runs through SignatureVerificationService, which handles the signature dialog, captures the signature receipt, and stamps the audit trail:
final approveAction = EntityAction<Certification>(
icon: FluentIcons.checkmark_seal_24_regular,
title: 'Approve',
isVisible: (c) async => c.status == 'under_review',
authorize: Authorize.permission('lms.certifications.approve'),
handler: (context, cert) async {
final svc = vyuh.entity?.services.verification;
if (svc == null) return;
await svc.verify(
context: context,
action: (
type: 'approve_certification',
title: 'Approve Certification: ${cert.displayTitle}',
handler: (result) async {
await certificationApi.approve(
cert.id,
signatureId: result.signatureId,
remarks: result.remarks,
);
},
),
entity: (
id: cert.id,
name: cert.displayTitle,
type: 'certifications',
typeLabel: 'Certification',
),
role: SignatureRole.reviewer,
requireSignature: true,
showRemarksField: true,
requireRemarks: true,
);
},
);No self-approval
The submitter must never approve their own certification. The SignatureVerificationService enforces this server-side regardless of client-side checks — 21 CFR Part 11 compliance.
Reject with Reason
final rejectAction = EntityAction<Certification>(
icon: FluentIcons.dismiss_circle_24_regular,
title: 'Reject',
isVisible: (c) async => c.status == 'under_review',
authorize: Authorize.permission('lms.certifications.approve'),
isDestructive: true,
handler: (context, cert) async {
final reason = await showReasonDialog(
context: context,
title: 'Reject "${cert.displayTitle}"',
label: 'Reason for rejection',
);
if (reason == null) return;
await certificationApi.reject(cert.id, reason: reason);
},
);Version History + Audit Trail
Because Certification mixes in Versionable, both layouts work out of the box:
EntityVersionLayout<Certification>(
authorize: Authorize.permission('lms.certifications.view'),
)
EntityAuditLayout<Certification>(
authorize: Authorize.permission('lms.certifications.view'),
)The versions tab lists every snapshot, lets you diff two versions side-by-side, and surfaces the responsible user. The audit tab records every action: created, submitted, approved (with the e-signature receipt), issued, revoked.
Expiration Handling
final renewAction = EntityAction<Certification>(
icon: FluentIcons.arrow_clockwise_24_regular,
title: 'Renew',
isVisible: (c) async => c.canRenew,
authorize: Authorize.permission('lms.certifications.approve'),
handler: (context, cert) async {
final newExpiry = await showDatePicker(
context: context,
initialDate: DateTime.now().add(const Duration(days: 365)),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 5)),
);
if (newExpiry == null) return;
await certificationApi.renew(cert.id, newExpiresAt: newExpiry);
},
);For ambient expiration monitoring, register a service that polls the realtime layer:
class ExpirationMonitorService extends EntityService {
RealtimeSubscription? _sub;
@override
String get name => 'expiration_monitor_service';
@override
Future<void> initialize() async {
final realtime = vyuh.entity?.services.realtime;
_sub = realtime?.subscribe(RealtimeConfig(
entityType: 'certifications',
onUpdate: _checkExpirations,
refreshInterval: const Duration(minutes: 5),
));
}
@override
void dispose() => _sub?.unsubscribe();
void _checkExpirations() {
// Inspect cached certifications and notify on imminent expiry.
}
}Register it in your feature:
EntityExtensionDescriptor(
entities: [certificationConfig],
services: [
EntityServiceRegistration<ExpirationMonitorService>(
ExpirationMonitorService(),
),
],
)Complete Configuration
import 'certification.entity.dart' as base show $certificationConfig;
final EntityConfiguration<Certification> certificationConfig =
base.$certificationConfig.copyWith(
layouts: EntityLayouts<Certification>(
list: [
EntityTableConfig<Certification>(
identifier: 'table',
title: 'Table',
icon: FluentIcons.table_24_regular,
columns: [
EntityTableColumn<Certification>(
fieldDef: CertificationFields.certificateNumber,
),
EntityTableColumn<Certification>(
fieldDef: CertificationFields.status,
),
EntityTableColumn<Certification>(
fieldDef: CertificationFields.courseId,
cellBuilder: (c, cert) =>
EntityNameText<Course>(entityId: cert.courseId),
),
EntityTableColumn<Certification>(
fieldDef: CertificationFields.participantId,
cellBuilder: (c, cert) =>
EntityNameText<Participant>(entityId: cert.participantId),
),
EntityTableColumn<Certification>(
fieldDef: CertificationFields.issuedAt,
),
EntityTableColumn<Certification>(
fieldDef: CertificationFields.expiresAt,
),
],
defaultSortField: 'issued_at',
defaultSortAscending: false,
),
],
details: [
const CertificationDetailLayout(),
EntityVersionLayout<Certification>(
authorize: Authorize.permission('lms.certifications.view'),
),
EntityAuditLayout<Certification>(
authorize: Authorize.permission('lms.certifications.view'),
),
],
),
actions: base.$certificationConfig.actions.copyWith(
inline: [
...StandardEntityActions.inline<Certification>(),
submitAction,
approveAction,
rejectAction,
EntityAction<Certification>(
icon: FluentIcons.ribbon_24_regular,
title: 'Issue Certificate',
isVisible: (c) async => c.status == 'approved',
authorize: Authorize.permission('lms.certifications.approve'),
handler: (context, cert) async => certificationApi.issue(cert.id),
),
EntityAction<Certification>(
icon: FluentIcons.prohibited_24_regular,
title: 'Revoke',
isVisible: (c) async => c.status == 'issued',
authorize: Authorize.permission('lms.certifications.approve'),
isDestructive: true,
handler: (context, cert) async {
final reason = await showReasonDialog(
context: context, title: 'Revoke Certificate',
label: 'Reason for revocation',
);
if (reason == null) return;
await certificationApi.revoke(cert.id, reason: reason);
},
),
renewAction,
],
),
filterPresets: FilterPresetHelper.createPresets(
fields: CertificationFields.instance.all,
definitions: [
FilterPresetDefinition(
id: 'pending',
name: 'Pending Review',
icon: FluentIcons.hourglass_24_regular,
builder: (s) => s.textField('status').equals('under_review'),
),
FilterPresetDefinition(
id: 'issued',
name: 'Issued',
icon: FluentIcons.checkmark_seal_24_regular,
builder: (s) => s.textField('status').equals('issued'),
),
FilterPresetDefinition(
id: 'expired',
name: 'Expired',
icon: FluentIcons.warning_24_regular,
builder: (s) => s.textField('status').equals('expired'),
),
],
),
);Generated Routes
| Route | Page |
|---|---|
/lms/certifications | List with status filter chips |
/lms/certifications/new | Create certification (admin only) |
/lms/certifications/:id | Detail with Details / Versions / Audit tabs |
/lms/certifications/:id/edit | Edit certification |
Next Steps
- Complete LMS Example — all five entity configurations
- Course Management — Course CRUD walkthrough
- Drafts and Versioning — the draft workflow in depth