<%= form.label :evidence_summary, "Evidence reviewed for every recipient", class: "block font-semibold" %><%= form.text_area :evidence_summary, required: true, rows: 4, placeholder: "Use a shared evidence statement only when it accurately applies to every recipient in this batch.", class: "mt-1 w-full rounded-lg border border-[var(--pu-input-border)] bg-[var(--pu-surface)] px-3 py-2" %>
This summary becomes public for every consenting recipient. Do not include private contact details or internal notes.
diff --git a/packages/admin_portal/app/views/plutonium/_resource_sidebar.html.erb b/packages/admin_portal/app/views/plutonium/_resource_sidebar.html.erb
index c22ecdd0..47b38118 100644
--- a/packages/admin_portal/app/views/plutonium/_resource_sidebar.html.erb
+++ b/packages/admin_portal/app/views/plutonium/_resource_sidebar.html.erb
@@ -12,6 +12,7 @@
n.item "Project credits", url: resource_url_for(Showcase::ProjectContributor, parent: nil)
end
m.item "Skills", url: resource_url_for(Skill, parent: nil), icon: Phlex::TablerIcons::Tools
+ m.item "Credentials", url: credential_awards_path, icon: Phlex::TablerIcons::Certificate
m.item "Accounts", icon: Phlex::TablerIcons::Users do |n|
n.item "Users", url: resource_url_for(User, parent: nil)
n.item "Admins", url: resource_url_for(Admin, parent: nil)
diff --git a/packages/admin_portal/config/routes.rb b/packages/admin_portal/config/routes.rb
index ba4df3cd..17f3c1c0 100644
--- a/packages/admin_portal/config/routes.rb
+++ b/packages/admin_portal/config/routes.rb
@@ -12,6 +12,21 @@
register_resource ::Network::Follow
register_resource ::Network::Endorsement
+ get "credentials", to: "credentials/awards#index", as: :credential_awards
+ get "credentials/export", to: "credentials/awards#export", as: :export_credential_awards
+ get "credentials/new", to: "credentials/awards#new", as: :new_credential_award
+ post "credentials/preview", to: "credentials/awards#preview", as: :preview_credential_awards
+ post "credentials", to: "credentials/awards#create"
+ get "credentials/batch/new", to: "credentials/awards#batch_new", as: :new_credential_batch
+ post "credentials/batch/preview", to: "credentials/awards#batch_preview", as: :preview_credential_batch
+ post "credentials/batch", to: "credentials/awards#batch_create", as: :credential_batch
+ get "credentials/:id", to: "credentials/awards#show", as: :credential_award
+ get "credentials/:id/edit", to: "credentials/awards#edit", as: :edit_credential_award
+ patch "credentials/:id", to: "credentials/awards#update"
+ post "credentials/:id/revoke", to: "credentials/awards#revoke", as: :revoke_credential_award
+ post "credentials/:id/expire", to: "credentials/awards#expire", as: :expire_credential_award
+ post "credentials/:id/reissue", to: "credentials/awards#reissue", as: :reissue_credential_award
+
# register resources above.
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
diff --git a/packages/credentials/app/jobs/credentials/expiry_job.rb b/packages/credentials/app/jobs/credentials/expiry_job.rb
new file mode 100644
index 00000000..b6dc21ff
--- /dev/null
+++ b/packages/credentials/app/jobs/credentials/expiry_job.rb
@@ -0,0 +1,9 @@
+module Credentials
+ class ExpiryJob < ApplicationJob
+ queue_as :default
+
+ def perform
+ Credentials::Award.active.where(expires_at: ..Time.current).find_each(&:expire_automatically!)
+ end
+ end
+end
diff --git a/packages/credentials/app/mailers/credentials/credential_mailer.rb b/packages/credentials/app/mailers/credentials/credential_mailer.rb
new file mode 100644
index 00000000..87ab872e
--- /dev/null
+++ b/packages/credentials/app/mailers/credentials/credential_mailer.rb
@@ -0,0 +1,26 @@
+module Credentials
+ class CredentialMailer < ::ApplicationMailer
+ prepend_view_path Credentials::Engine.root.join("app/views")
+
+ before_action do
+ @award = params[:award]
+ @profile = @award.recipient
+ @definition = @award.definition
+ end
+
+ def invitation
+ @credential_url = absolute_url("/developer/#{@profile.to_param}/credentials/#{@award.public_id}")
+ mail to: @profile.user.email, subject: "Review your #{@definition.name} credential"
+ end
+
+ def issued
+ @verification_url = absolute_url("/credentials/#{@award.public_id}")
+ mail to: @profile.user.email, subject: "Your #{@definition.name} credential is ready"
+ end
+
+ def revoked
+ @verification_url = absolute_url("/credentials/#{@award.public_id}")
+ mail to: @profile.user.email, subject: "Update to your #{@definition.name} credential"
+ end
+ end
+end
diff --git a/packages/credentials/app/models/credentials.rb b/packages/credentials/app/models/credentials.rb
new file mode 100644
index 00000000..ddcf9eb5
--- /dev/null
+++ b/packages/credentials/app/models/credentials.rb
@@ -0,0 +1,5 @@
+module Credentials
+ def self.table_name_prefix
+ "credentials_"
+ end
+end
diff --git a/packages/credentials/app/models/credentials/award.rb b/packages/credentials/app/models/credentials/award.rb
new file mode 100644
index 00000000..02eb9572
--- /dev/null
+++ b/packages/credentials/app/models/credentials/award.rb
@@ -0,0 +1,185 @@
+# == Schema Information
+#
+# Table name: credentials_awards
+#
+# id :integer not null, primary key
+# consented_at :datetime
+# criteria_version :string not null
+# evidence_summary :text not null
+# expires_at :datetime
+# issued_at :datetime
+# recipient_name :string not null
+# revoked_at :datetime
+# revoked_reason :text
+# status :integer default("pending_consent"), not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# created_by_admin_id :integer not null
+# definition_id :integer not null
+# public_id :string not null
+# recipient_id :integer not null
+# supersedes_id :integer
+# updated_by_admin_id :integer
+#
+# Indexes
+#
+# index_credentials_awards_on_created_by_admin_id (created_by_admin_id)
+# index_credentials_awards_on_current_achievement (definition_id,recipient_id) UNIQUE WHERE status IN (0, 1)
+# index_credentials_awards_on_definition_id (definition_id)
+# index_credentials_awards_on_public_id (public_id) UNIQUE
+# index_credentials_awards_on_recipient_id (recipient_id)
+# index_credentials_awards_on_status_and_created_at (status,created_at)
+# index_credentials_awards_on_supersedes_id (supersedes_id)
+# index_credentials_awards_on_updated_by_admin_id (updated_by_admin_id)
+#
+# Foreign Keys
+#
+# created_by_admin_id (created_by_admin_id => admins.id) ON DELETE => restrict
+# definition_id (definition_id => credentials_definitions.id) ON DELETE => restrict
+# recipient_id (recipient_id => developers_profiles.id) ON DELETE => restrict
+# supersedes_id (supersedes_id => credentials_awards.id) ON DELETE => nullify
+# updated_by_admin_id (updated_by_admin_id => admins.id) ON DELETE => nullify
+#
+require "securerandom"
+require_relative "../credentials"
+
+module Credentials
+ class Award < Credentials::ResourceRecord
+ LINKEDIN_ADD_TO_PROFILE_URL = "https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME"
+ PUBLIC_ID_LENGTH = 24
+ PUBLIC_ID_FORMAT = /\A[1-9A-HJ-NP-Za-km-z]{#{PUBLIC_ID_LENGTH}}\z/
+
+ enum :status, {pending_consent: 0, active: 1, expired: 2, revoked: 3}
+
+ belongs_to :definition, class_name: "Credentials::Definition"
+ belongs_to :recipient, class_name: "Developers::Profile"
+ belongs_to :supersedes, class_name: "Credentials::Award", optional: true
+ belongs_to :created_by_admin, class_name: "Admin"
+ belongs_to :updated_by_admin, class_name: "Admin", optional: true
+
+ has_many :events, -> { order(created_at: :asc) }, class_name: "Credentials::Event", dependent: :destroy
+ has_one :replacement, class_name: "Credentials::Award", foreign_key: :supersedes_id, dependent: :nullify
+
+ before_validation :assign_public_id, on: :create
+ before_validation :snapshot_definition, on: :create
+ before_validation :snapshot_recipient_name, on: :create
+
+ validates :public_id, presence: true, uniqueness: true,
+ format: {with: PUBLIC_ID_FORMAT}
+ validates :recipient_name, :evidence_summary, :criteria_version, presence: true
+ validates :recipient_id, uniqueness: {
+ scope: :definition_id,
+ conditions: -> { where(status: %i[pending_consent active]) },
+ message: "already has a current credential for this achievement"
+ }
+ validate :lifecycle_timestamps_match_status
+
+ scope :publicly_verifiable, -> { where(status: %i[active expired revoked]) }
+ scope :newest, -> { order(created_at: :desc) }
+
+ def to_param
+ public_id
+ end
+
+ def publicly_verifiable?
+ active? || expired? || revoked?
+ end
+
+ def accept!(user:)
+ raise ActiveRecord::RecordNotFound unless recipient.user_id == user.id
+ raise ActiveRecord::RecordInvalid, self unless pending_consent?
+
+ now = Time.current
+ transaction do
+ update!(status: :active, consented_at: now, issued_at: now,
+ expires_at: definition.expiry_from(now))
+ record_event!("consent_recorded", user:, reason: "Recipient accepted public credential issuance")
+ record_event!("issued", user:)
+ end
+ self
+ end
+
+ def correct!(attributes, admin:, reason:)
+ raise ArgumentError, "A correction reason is required" if reason.blank?
+
+ allowed = attributes.slice(:recipient_name, :evidence_summary, :expires_at)
+ before = self.attributes.slice(*allowed.keys.map(&:to_s))
+ transaction do
+ update!(allowed.merge(updated_by_admin: admin))
+ record_event!("corrected", admin:, reason:, changeset: {before:, after: self.attributes.slice(*before.keys)})
+ end
+ self
+ end
+
+ def revoke!(admin:, reason:)
+ raise ArgumentError, "A revocation reason is required" if reason.blank?
+ raise ActiveRecord::RecordInvalid, self unless active?
+
+ transaction do
+ update!(status: :revoked, revoked_at: Time.current, revoked_reason: reason, updated_by_admin: admin)
+ record_event!("revoked", admin:, reason:)
+ end
+ self
+ end
+
+ def expire!(admin:, reason:)
+ raise ArgumentError, "An expiration reason is required" if reason.blank?
+ raise ActiveRecord::RecordInvalid, self unless active?
+
+ transaction do
+ update!(status: :expired, expires_at: [expires_at, Time.current].compact.min, updated_by_admin: admin)
+ record_event!("expired", admin:, reason:)
+ end
+ self
+ end
+
+ def expire_automatically!
+ return self unless active? && expires_at&.past?
+
+ transaction do
+ update!(status: :expired)
+ record_event!("expired", reason: "Credential reached its configured expiry date")
+ end
+ self
+ end
+
+ def linkedin_fields(verification_url:)
+ {
+ "Credential name" => definition.name,
+ "Issuing organization" => definition.issuing_authority,
+ "Issue date" => issued_at&.strftime("%B %Y"),
+ "Expiration date" => expires_at&.strftime("%B %Y"),
+ "Credential ID" => public_id,
+ "Credential URL" => verification_url
+ }.compact
+ end
+
+ def record_event!(event_type, admin: nil, user: nil, reason: nil, changeset: {})
+ events.create!(event_type:, admin:, user:, reason:, changeset:)
+ end
+
+ private
+
+ def assign_public_id
+ self.public_id ||= loop do
+ candidate = SecureRandom.base58(PUBLIC_ID_LENGTH)
+ break candidate unless self.class.exists?(public_id: candidate)
+ end
+ end
+
+ def snapshot_definition
+ self.criteria_version ||= definition&.criteria_version
+ end
+
+ def snapshot_recipient_name
+ self.recipient_name ||= recipient&.name
+ end
+
+ def lifecycle_timestamps_match_status
+ errors.add(:consented_at, "is required for an issued credential") if active? && consented_at.blank?
+ errors.add(:issued_at, "is required for an issued credential") if active? && issued_at.blank?
+ errors.add(:revoked_at, "is required for a revoked credential") if revoked? && revoked_at.blank?
+ errors.add(:revoked_reason, "is required for a revoked credential") if revoked? && revoked_reason.blank?
+ end
+ end
+end
diff --git a/packages/credentials/app/models/credentials/definition.rb b/packages/credentials/app/models/credentials/definition.rb
new file mode 100644
index 00000000..63a70d79
--- /dev/null
+++ b/packages/credentials/app/models/credentials/definition.rb
@@ -0,0 +1,51 @@
+# == Schema Information
+#
+# Table name: credentials_definitions
+#
+# id :integer not null, primary key
+# active :boolean default(TRUE), not null
+# approver_roles :json not null
+# badge_alt_text :string not null
+# criteria_version :string not null
+# description :text not null
+# does_not_prove :text not null
+# eligibility_criteria :text not null
+# evidence_requirements :text not null
+# expires_after_months :integer
+# issuing_authority :string default("DevCongress"), not null
+# lifecycle_rules :text not null
+# name :string not null
+# short_description :string not null
+# slug :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+# Indexes
+#
+# index_credentials_definitions_on_slug (slug) UNIQUE
+#
+require_relative "../credentials"
+
+module Credentials
+ class Definition < Credentials::ResourceRecord
+ has_many :awards, class_name: "Credentials::Award", dependent: :restrict_with_error
+
+ scope :available, -> { where(active: true).order(:name) }
+
+ validates :name, :slug, :short_description, :description, :eligibility_criteria,
+ :evidence_requirements, :issuing_authority, :criteria_version, :lifecycle_rules,
+ :does_not_prove, :badge_alt_text, presence: true
+ validates :slug, uniqueness: true, format: {with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/}
+ validates :expires_after_months, numericality: {only_integer: true, greater_than: 0}, allow_nil: true
+
+ normalizes :slug, with: ->(slug) { slug.to_s.strip.downcase.parameterize }
+
+ def expiry_from(time)
+ expires_after_months&.months&.after(time)
+ end
+
+ def to_label
+ name
+ end
+ end
+end
diff --git a/packages/credentials/app/models/credentials/event.rb b/packages/credentials/app/models/credentials/event.rb
new file mode 100644
index 00000000..2ee77e53
--- /dev/null
+++ b/packages/credentials/app/models/credentials/event.rb
@@ -0,0 +1,43 @@
+# == Schema Information
+#
+# Table name: credentials_events
+#
+# id :integer not null, primary key
+# changeset :json not null
+# event_type :string not null
+# reason :text
+# created_at :datetime not null
+# updated_at :datetime not null
+# admin_id :integer
+# award_id :integer not null
+# user_id :integer
+#
+# Indexes
+#
+# index_credentials_events_on_admin_id (admin_id)
+# index_credentials_events_on_award_id (award_id)
+# index_credentials_events_on_award_id_and_created_at (award_id,created_at)
+# index_credentials_events_on_event_type_and_created_at (event_type,created_at)
+# index_credentials_events_on_user_id (user_id)
+#
+# Foreign Keys
+#
+# admin_id (admin_id => admins.id) ON DELETE => nullify
+# award_id (award_id => credentials_awards.id) ON DELETE => cascade
+# user_id (user_id => users.id) ON DELETE => nullify
+#
+require_relative "../credentials"
+
+module Credentials
+ class Event < Credentials::ResourceRecord
+ belongs_to :award, class_name: "Credentials::Award"
+ belongs_to :admin, optional: true
+ belongs_to :user, optional: true
+
+ validates :event_type, presence: true
+
+ def actor_label
+ admin&.email || user&.email || "System"
+ end
+ end
+end
diff --git a/packages/credentials/app/models/credentials/resource_record.rb b/packages/credentials/app/models/credentials/resource_record.rb
new file mode 100644
index 00000000..00f75ba2
--- /dev/null
+++ b/packages/credentials/app/models/credentials/resource_record.rb
@@ -0,0 +1,5 @@
+module Credentials
+ class ResourceRecord < ::ResourceRecord
+ self.abstract_class = true
+ end
+end
diff --git a/packages/credentials/app/services/credentials/issuer.rb b/packages/credentials/app/services/credentials/issuer.rb
new file mode 100644
index 00000000..d40f329f
--- /dev/null
+++ b/packages/credentials/app/services/credentials/issuer.rb
@@ -0,0 +1,49 @@
+module Credentials
+ class Issuer
+ Result = Data.define(:award, :created)
+
+ def initialize(definition:, admin:)
+ @definition = definition
+ @admin = admin
+ raise ArgumentError, "This administrator cannot approve #{@definition.name}" unless @definition.approver_roles.include?(@admin.role)
+ end
+
+ def preview(profile:, evidence_summary:, expires_at: nil, supersedes: nil)
+ Credentials::Award.new(
+ definition: @definition,
+ recipient: profile,
+ recipient_name: profile.name,
+ evidence_summary:,
+ expires_at:,
+ supersedes:,
+ created_by_admin: @admin
+ )
+ end
+
+ def issue!(profile:, evidence_summary:, expires_at: nil, supersedes: nil, notify: true)
+ current = current_award_for(profile)
+ return Result.new(current, false) if current
+
+ award = preview(profile:, evidence_summary:, expires_at:, supersedes:)
+ Credentials::Award.transaction do
+ award.save!
+ award.record_event!("invitation_created", admin: @admin,
+ reason: "Evidence reviewed against criteria #{award.criteria_version}")
+ end
+ Credentials::CredentialMailer.with(award:).invitation.deliver_later if notify
+ Result.new(award, true)
+ rescue ActiveRecord::RecordNotUnique
+ Result.new(current_award_for(profile), false)
+ end
+
+ private
+
+ def current_award_for(profile)
+ Credentials::Award.find_by(
+ definition: @definition,
+ recipient: profile,
+ status: %i[pending_consent active]
+ )
+ end
+ end
+end
diff --git a/packages/credentials/app/views/credentials/credential_mailer/invitation.html.erb b/packages/credentials/app/views/credentials/credential_mailer/invitation.html.erb
new file mode 100644
index 00000000..bfb1b52b
--- /dev/null
+++ b/packages/credentials/app/views/credentials/credential_mailer/invitation.html.erb
@@ -0,0 +1,9 @@
+
Hello <%= @profile.first_name %>,
+
+
DevCongress reviewed your contribution and found it eligible for the <%= @definition.name %> credential.
+
+
Review the evidence and criteria, then choose whether to accept the credential and make its verification page public.
+
+
<%= link_to "Review your credential", @credential_url %>
+
+
Nothing is published until you accept. A LinkedIn account is not required.
diff --git a/packages/credentials/app/views/credentials/credential_mailer/invitation.text.erb b/packages/credentials/app/views/credentials/credential_mailer/invitation.text.erb
new file mode 100644
index 00000000..7bf77fb7
--- /dev/null
+++ b/packages/credentials/app/views/credentials/credential_mailer/invitation.text.erb
@@ -0,0 +1,8 @@
+Hello <%= @profile.first_name %>,
+
+DevCongress reviewed your contribution and found it eligible for the <%= @definition.name %> credential.
+
+Review the evidence and criteria, then choose whether to accept the credential and make its verification page public:
+<%= @credential_url %>
+
+Nothing is published until you accept. A LinkedIn account is not required.
diff --git a/packages/credentials/app/views/credentials/credential_mailer/issued.html.erb b/packages/credentials/app/views/credentials/credential_mailer/issued.html.erb
new file mode 100644
index 00000000..7fc79a1a
--- /dev/null
+++ b/packages/credentials/app/views/credentials/credential_mailer/issued.html.erb
@@ -0,0 +1,7 @@
+
Hello <%= @profile.first_name %>,
+
+
Your <%= @definition.name %> credential has been issued.
+
+
<%= link_to "View and share your verified credential", @verification_url %>
+
+
The verification page includes the exact values to copy into LinkedIn, plus downloadable badge and certificate files.
diff --git a/packages/credentials/app/views/credentials/credential_mailer/issued.text.erb b/packages/credentials/app/views/credentials/credential_mailer/issued.text.erb
new file mode 100644
index 00000000..ccd9da90
--- /dev/null
+++ b/packages/credentials/app/views/credentials/credential_mailer/issued.text.erb
@@ -0,0 +1,8 @@
+Hello <%= @profile.first_name %>,
+
+Your <%= @definition.name %> credential has been issued.
+
+View and share your verified credential:
+<%= @verification_url %>
+
+The verification page includes the exact values to copy into LinkedIn, plus downloadable badge and certificate files.
diff --git a/packages/credentials/app/views/credentials/credential_mailer/revoked.html.erb b/packages/credentials/app/views/credentials/credential_mailer/revoked.html.erb
new file mode 100644
index 00000000..b143c8d9
--- /dev/null
+++ b/packages/credentials/app/views/credentials/credential_mailer/revoked.html.erb
@@ -0,0 +1,7 @@
+
Hello <%= @profile.first_name %>,
+
+
Your <%= @definition.name %> credential has been revoked.
+
+
Reason: <%= @award.revoked_reason %>
+
+
<%= link_to "View the credential status", @verification_url %>
Accepting publishes your name, the credential, the issue date, its criteria version, the evidence summary above, status, and credential ID at a stable verification URL. Your email and private profile information remain private.
Adding it to LinkedIn is optional and does not require connecting your LinkedIn account.
<%= button_to "Accept and issue credential", developers_profile_scoped_accept_credential_award_path(developers_profile_scoped: @profile, id: @award.public_id), class: "pu-btn pu-btn-md pu-btn-primary", data: {turbo_confirm: "Accept this credential and publish its verification page?"} %>