Skip to content

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).

dart
@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:

dart
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:

dart
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

dart
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:

dart
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

dart
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:

dart
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

dart
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:

dart
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:

dart
EntityExtensionDescriptor(
  entities: [certificationConfig],
  services: [
    EntityServiceRegistration<ExpirationMonitorService>(
      ExpirationMonitorService(),
    ),
  ],
)

Complete Configuration

dart
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

RoutePage
/lms/certificationsList with status filter chips
/lms/certifications/newCreate certification (admin only)
/lms/certifications/:idDetail with Details / Versions / Audit tabs
/lms/certifications/:id/editEdit certification

Next Steps