From 36c398b64d6fcf66aadd09e0e52679a934bad540 Mon Sep 17 00:00:00 2001 From: Yaw Boakye at Zebo Date: Fri, 23 Oct 2026 13:21:30 +0100 Subject: [PATCH 1/2] add credential issuance and consent workflow --- app/models/admin.rb | 2 + config/recurring.yml | 6 + db/schema.rb | 74 ++++++- db/seeds.rb | 21 ++ .../credentials/awards_controller.rb | 191 ++++++++++++++++++ .../credentials/awards/batch_new.html.erb | 5 + .../credentials/awards/batch_preview.html.erb | 10 + .../credentials/awards/edit.html.erb | 5 + .../credentials/awards/index.html.erb | 54 +++++ .../credentials/awards/new.html.erb | 46 +++++ .../credentials/awards/preview.html.erb | 37 ++++ .../credentials/awards/show.html.erb | 23 +++ .../plutonium/_resource_sidebar.html.erb | 1 + packages/admin_portal/config/routes.rb | 15 ++ .../app/jobs/credentials/expiry_job.rb | 9 + .../mailers/credentials/credential_mailer.rb | 26 +++ .../credentials/app/models/credentials.rb | 5 + .../app/models/credentials/award.rb | 185 +++++++++++++++++ .../app/models/credentials/definition.rb | 51 +++++ .../app/models/credentials/event.rb | 43 ++++ .../app/models/credentials/resource_record.rb | 5 + .../app/services/credentials/issuer.rb | 49 +++++ .../credential_mailer/invitation.html.erb | 9 + .../credential_mailer/invitation.text.erb | 8 + .../credential_mailer/issued.html.erb | 7 + .../credential_mailer/issued.text.erb | 8 + .../credential_mailer/revoked.html.erb | 7 + .../credential_mailer/revoked.text.erb | 8 + .../20261023090000_create_credentials.rb | 63 ++++++ packages/credentials/lib/engine.rb | 5 + .../credentials/awards_controller.rb | 33 +++ .../credentials/awards/index.html.erb | 13 ++ .../credentials/awards/show.html.erb | 10 + .../plutonium/_resource_sidebar.html.erb | 1 + packages/developer_portal/config/routes.rb | 5 + .../app/models/developers/profile.rb | 1 + .../credentials/credential_workflow_test.rb | 95 +++++++++ test/jobs/credentials/expiry_job_test.rb | 16 ++ test/models/credentials/award_test.rb | 101 +++++++++ test/support/accounts.rb | 29 +++ 40 files changed, 1281 insertions(+), 1 deletion(-) create mode 100644 packages/admin_portal/app/controllers/admin_portal/credentials/awards_controller.rb create mode 100644 packages/admin_portal/app/views/admin_portal/credentials/awards/batch_new.html.erb create mode 100644 packages/admin_portal/app/views/admin_portal/credentials/awards/batch_preview.html.erb create mode 100644 packages/admin_portal/app/views/admin_portal/credentials/awards/edit.html.erb create mode 100644 packages/admin_portal/app/views/admin_portal/credentials/awards/index.html.erb create mode 100644 packages/admin_portal/app/views/admin_portal/credentials/awards/new.html.erb create mode 100644 packages/admin_portal/app/views/admin_portal/credentials/awards/preview.html.erb create mode 100644 packages/admin_portal/app/views/admin_portal/credentials/awards/show.html.erb create mode 100644 packages/credentials/app/jobs/credentials/expiry_job.rb create mode 100644 packages/credentials/app/mailers/credentials/credential_mailer.rb create mode 100644 packages/credentials/app/models/credentials.rb create mode 100644 packages/credentials/app/models/credentials/award.rb create mode 100644 packages/credentials/app/models/credentials/definition.rb create mode 100644 packages/credentials/app/models/credentials/event.rb create mode 100644 packages/credentials/app/models/credentials/resource_record.rb create mode 100644 packages/credentials/app/services/credentials/issuer.rb create mode 100644 packages/credentials/app/views/credentials/credential_mailer/invitation.html.erb create mode 100644 packages/credentials/app/views/credentials/credential_mailer/invitation.text.erb create mode 100644 packages/credentials/app/views/credentials/credential_mailer/issued.html.erb create mode 100644 packages/credentials/app/views/credentials/credential_mailer/issued.text.erb create mode 100644 packages/credentials/app/views/credentials/credential_mailer/revoked.html.erb create mode 100644 packages/credentials/app/views/credentials/credential_mailer/revoked.text.erb create mode 100644 packages/credentials/db/migrate/20261023090000_create_credentials.rb create mode 100644 packages/credentials/lib/engine.rb create mode 100644 packages/developer_portal/app/controllers/developer_portal/credentials/awards_controller.rb create mode 100644 packages/developer_portal/app/views/developer_portal/credentials/awards/index.html.erb create mode 100644 packages/developer_portal/app/views/developer_portal/credentials/awards/show.html.erb create mode 100644 test/integration/credentials/credential_workflow_test.rb create mode 100644 test/jobs/credentials/expiry_job_test.rb create mode 100644 test/models/credentials/award_test.rb diff --git a/app/models/admin.rb b/app/models/admin.rb index e8172c0b..51a85a52 100644 --- a/app/models/admin.rb +++ b/app/models/admin.rb @@ -30,6 +30,8 @@ class Admin < ResourceRecord # add has_one associations above. # add has_many associations above. + has_many :created_credential_awards, class_name: "Credentials::Award", foreign_key: :created_by_admin_id, dependent: :restrict_with_error + has_many :credential_events, class_name: "Credentials::Event", dependent: :nullify # add attachments above. diff --git a/config/recurring.yml b/config/recurring.yml index 8c04d92e..9b4b57d2 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -37,3 +37,9 @@ production: queue: default schedule: every hour at minute 20 description: "Close #jobs Slack messages for jobs that have expired" + + credential_expiry: + class: Credentials::ExpiryJob + queue: default + schedule: every hour at minute 35 + description: "Mark credentials past their configured expiry date as expired" diff --git a/db/schema.rb b/db/schema.rb index 491d3075..fcbb8128 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_14_090000) do +ActiveRecord::Schema[8.1].define(version: 2026_10_23_090000) do create_table "active_shrine_attachments", force: :cascade do |t| t.string "record_type" t.bigint "record_id" @@ -142,6 +142,70 @@ t.index ["user_id"], name: "index_company_users_on_user_id" end + create_table "credentials_awards", force: :cascade do |t| + t.integer "definition_id", null: false + t.integer "recipient_id", null: false + t.string "public_id", null: false + t.string "recipient_name", null: false + t.text "evidence_summary", null: false + t.string "criteria_version", null: false + t.integer "status", default: 0, null: false + t.datetime "consented_at" + t.datetime "issued_at" + t.datetime "expires_at" + t.datetime "revoked_at" + t.text "revoked_reason" + t.integer "supersedes_id" + t.integer "created_by_admin_id", null: false + t.integer "updated_by_admin_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_by_admin_id"], name: "index_credentials_awards_on_created_by_admin_id" + t.index ["definition_id", "recipient_id"], name: "index_credentials_awards_on_current_achievement", unique: true, where: "status IN (0, 1)" + t.index ["definition_id"], name: "index_credentials_awards_on_definition_id" + t.index ["public_id"], name: "index_credentials_awards_on_public_id", unique: true + t.index ["recipient_id"], name: "index_credentials_awards_on_recipient_id" + t.index ["status", "created_at"], name: "index_credentials_awards_on_status_and_created_at" + t.index ["supersedes_id"], name: "index_credentials_awards_on_supersedes_id" + t.index ["updated_by_admin_id"], name: "index_credentials_awards_on_updated_by_admin_id" + end + + create_table "credentials_definitions", force: :cascade do |t| + t.string "name", null: false + t.string "slug", null: false + t.string "short_description", null: false + t.text "description", null: false + t.text "eligibility_criteria", null: false + t.text "evidence_requirements", null: false + t.string "issuing_authority", default: "DevCongress", null: false + t.json "approver_roles", default: ["super_admin", "admin"], null: false + t.string "criteria_version", null: false + t.text "lifecycle_rules", null: false + t.text "does_not_prove", null: false + t.string "badge_alt_text", null: false + t.integer "expires_after_months" + t.boolean "active", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["slug"], name: "index_credentials_definitions_on_slug", unique: true + end + + create_table "credentials_events", force: :cascade do |t| + t.integer "award_id", null: false + t.integer "admin_id" + t.integer "user_id" + t.string "event_type", null: false + t.text "reason" + t.json "changeset", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["admin_id"], name: "index_credentials_events_on_admin_id" + t.index ["award_id", "created_at"], name: "index_credentials_events_on_award_id_and_created_at" + t.index ["award_id"], name: "index_credentials_events_on_award_id" + t.index ["event_type", "created_at"], name: "index_credentials_events_on_event_type_and_created_at" + t.index ["user_id"], name: "index_credentials_events_on_user_id" + end + create_table "developers_experience_skills", force: :cascade do |t| t.integer "experience_id", null: false t.integer "profile_skill_id", null: false @@ -414,6 +478,14 @@ add_foreign_key "company_user_invites", "users" add_foreign_key "company_users", "companies" add_foreign_key "company_users", "users" + add_foreign_key "credentials_awards", "admins", column: "created_by_admin_id", on_delete: :restrict + add_foreign_key "credentials_awards", "admins", column: "updated_by_admin_id", on_delete: :nullify + add_foreign_key "credentials_awards", "credentials_awards", column: "supersedes_id", on_delete: :nullify + add_foreign_key "credentials_awards", "credentials_definitions", column: "definition_id", on_delete: :restrict + add_foreign_key "credentials_awards", "developers_profiles", column: "recipient_id", on_delete: :restrict + add_foreign_key "credentials_events", "admins", on_delete: :nullify + add_foreign_key "credentials_events", "credentials_awards", column: "award_id", on_delete: :cascade + add_foreign_key "credentials_events", "users", on_delete: :nullify add_foreign_key "developers_experience_skills", "developers_experiences", column: "experience_id", on_delete: :cascade add_foreign_key "developers_experience_skills", "developers_profile_skills", column: "profile_skill_id", on_delete: :cascade add_foreign_key "developers_experiences", "companies" diff --git a/db/seeds.rb b/db/seeds.rb index aeb90903..64fee71d 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -35,3 +35,24 @@ Skill.upsert_all(changed, unique_by: :index_skills_on_slug, update_only: %i[name category description website_url source_url why_description company_examples content_curated updated_at]) end + +# The first DevCongress credential. Definitions live in the database so later +# achievements can be added without changing the issuance or verification flow. +Credentials::Definition.find_or_initialize_by(slug: "community-contributor").tap do |definition| + definition.assign_attributes( + name: "DevCongress Community Contributor", + short_description: "Recognizes a reviewed, accepted contribution that materially advanced a DevCongress program, event, project, or community resource.", + description: "The DevCongress Community Contributor credential recognizes someone whose completed contribution created clear value for the DevCongress community. It is awarded only after an authorized DevCongress administrator reviews evidence of the work against the published criteria.", + eligibility_criteria: "The recipient must have completed at least one contribution accepted by the responsible DevCongress program, event, or project lead. The contribution must have a concrete outcome that benefited the community, and the recipient's role must be supported by first-party evidence rather than self-report alone.", + evidence_requirements: "A reviewer must record a concise account of the contribution and retain or reference first-party evidence such as a merged contribution, published resource, organizer record, program deliverable, or written confirmation from the responsible lead.", + issuing_authority: "DevCongress", + approver_roles: %w[super_admin admin], + criteria_version: "1.0", + lifecycle_rules: "This credential does not expire. DevCongress may correct factual errors, revoke a credential when evidence is invalidated or misconduct materially undermines the award, and reissue it when a correction requires a new credential ID. Every lifecycle action requires a reason and remains in the audit trail.", + does_not_prove: "This credential does not represent employment by DevCongress, an academic degree, professional licensure, identity verification, or endorsement by LinkedIn. It verifies only the reviewed DevCongress contribution described on this page.", + badge_alt_text: "DevCongress Community Contributor credential badge", + expires_after_months: nil, + active: true + ) + definition.save! +end diff --git a/packages/admin_portal/app/controllers/admin_portal/credentials/awards_controller.rb b/packages/admin_portal/app/controllers/admin_portal/credentials/awards_controller.rb new file mode 100644 index 00000000..675e6925 --- /dev/null +++ b/packages/admin_portal/app/controllers/admin_portal/credentials/awards_controller.rb @@ -0,0 +1,191 @@ +require "csv" + +module AdminPortal + module Credentials + class AwardsController < AdminPortal::PlutoniumController + before_action :set_award, only: %i[show edit update revoke expire reissue] + + def index + @query = params[:q].to_s.strip + @status = params[:status].presence + @awards = ::Credentials::Award.includes(:definition, recipient: :user).newest + @awards = @awards.where(status: @status) if ::Credentials::Award.statuses.key?(@status) + if @query.present? + term = "%#{ActiveRecord::Base.sanitize_sql_like(@query.downcase)}%" + @awards = @awards.joins(recipient: :user).where( + "LOWER(credentials_awards.recipient_name) LIKE :term OR " \ + "LOWER(credentials_awards.public_id) LIKE :term OR " \ + "LOWER(developers_profiles.handle) LIKE :term OR " \ + "LOWER(users.email) LIKE :term", term: + ) + end + end + + def show + end + + def new + @definitions = ::Credentials::Definition.available + @definition = @definitions.find_by(id: params[:definition_id]) || @definitions.first + end + + def preview + @definition = selected_definition + @profile = find_profile!(credential_params.fetch(:recipient_identifier)) + @award = issuer(@definition).preview( + profile: @profile, + evidence_summary: credential_params.fetch(:evidence_summary), + expires_at: parsed_expiry + ) + @award.validate + render :preview, status: (@award.errors.any? ? :unprocessable_content : :ok) + rescue ActiveRecord::RecordNotFound, ActionController::ParameterMissing => error + redirect_to new_credential_award_path, alert: error.message + end + + def create + definition = selected_definition + profile = find_profile!(credential_params.fetch(:recipient_identifier)) + result = issuer(definition).issue!( + profile:, + evidence_summary: credential_params.fetch(:evidence_summary), + expires_at: parsed_expiry + ) + notice = result.created ? "Credential invitation created and emailed." : "The existing current credential was reused; no duplicate was created." + redirect_to credential_award_path(result.award), notice: + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound, ActionController::ParameterMissing => error + redirect_to new_credential_award_path, alert: error.message + end + + def edit + end + + def update + @award.correct!(correction_params, admin: current_user, reason: params.require(:reason)) + redirect_to credential_award_path(@award), notice: "Credential details corrected and recorded in the audit trail." + rescue ArgumentError, ActiveRecord::RecordInvalid, ActionController::ParameterMissing => error + flash.now[:alert] = error.message + render :edit, status: :unprocessable_content + end + + def revoke + @award.revoke!(admin: current_user, reason: params.require(:reason)) + ::Credentials::CredentialMailer.with(award: @award).revoked.deliver_later + redirect_to credential_award_path(@award), notice: "Credential revoked. Its verification URL remains available." + rescue ArgumentError, ActiveRecord::RecordInvalid, ActionController::ParameterMissing => error + redirect_to credential_award_path(@award), alert: error.message + end + + def expire + @award.expire!(admin: current_user, reason: params.require(:reason)) + redirect_to credential_award_path(@award), notice: "Credential marked expired. Its verification URL remains available." + rescue ArgumentError, ActiveRecord::RecordInvalid, ActionController::ParameterMissing => error + redirect_to credential_award_path(@award), alert: error.message + end + + def reissue + reason = params.require(:reason) + @award.revoke!(admin: current_user, reason: "Reissued: #{reason}") if @award.active? + result = issuer(@award.definition).issue!( + profile: @award.recipient, + evidence_summary: @award.evidence_summary, + supersedes: @award + ) + @award.record_event!("reissued", admin: current_user, reason:, changeset: {replacement_public_id: result.award.public_id}) + redirect_to credential_award_path(result.award), notice: "Replacement credential invitation created." + rescue ArgumentError, ActiveRecord::RecordInvalid, ActionController::ParameterMissing => error + redirect_to credential_award_path(@award), alert: error.message + end + + def batch_new + @definitions = ::Credentials::Definition.available + end + + def batch_preview + @definition = ::Credentials::Definition.available.find(batch_params.fetch(:definition_id)) + @evidence_summary = batch_params.fetch(:evidence_summary) + identifiers = batch_params.fetch(:recipient_identifiers).lines.map(&:strip).reject(&:blank?).uniq + @rows = identifiers.map do |identifier| + profile = find_profile(identifier) + existing = profile && current_award(@definition, profile) + {identifier:, profile:, existing:} + end + render :batch_preview + rescue ActionController::ParameterMissing => error + redirect_to new_credential_batch_path, alert: error.message + end + + def batch_create + raise ActionController::BadRequest, "Explicit confirmation is required" unless params[:confirm] == "issue" + + definition = ::Credentials::Definition.available.find(params.require(:definition_id)) + evidence_summary = params.require(:evidence_summary) + profiles = ::Developers::Profile.where(id: Array(params[:profile_ids]).uniq) + results = profiles.map { |profile| issuer(definition).issue!(profile:, evidence_summary:) } + created = results.count(&:created) + skipped = results.size - created + redirect_to credential_awards_path, + notice: "Created #{created} credential #{"invitation".pluralize(created)}; skipped #{skipped} existing #{"credential".pluralize(skipped)}." + rescue ActiveRecord::RecordInvalid, ActionController::ParameterMissing, ActionController::BadRequest => error + redirect_to new_credential_batch_path, alert: error.message + end + + def export + events = ::Credentials::Event.includes(:admin, :user, award: [:definition, {recipient: :user}]).order(:created_at) + csv = CSV.generate(headers: true) do |rows| + rows << %w[event_at event credential_id credential recipient recipient_email status actor reason changes] + events.each do |event| + award = event.award + rows << [event.created_at.iso8601, event.event_type, award.public_id, award.definition.name, + award.recipient_name, award.recipient.user.email, award.status, event.actor_label, + event.reason, event.changeset.to_json] + end + end + send_data csv, filename: "devcongress-credential-audit-#{Date.current.iso8601}.csv", type: "text/csv" + end + + private + + def set_award + @award = ::Credentials::Award.includes(:definition, :recipient, events: [:admin, :user]).find_by!(public_id: params[:id]) + end + + def selected_definition + ::Credentials::Definition.available.find(credential_params.fetch(:definition_id)) + end + + def credential_params + params.require(:credential).permit(:definition_id, :recipient_identifier, :evidence_summary, :expires_at) + end + + def correction_params + params.require(:credential).permit(:recipient_name, :evidence_summary, :expires_at) + end + + def batch_params + params.require(:batch).permit(:definition_id, :recipient_identifiers, :evidence_summary) + end + + def parsed_expiry + credential_params[:expires_at].presence + end + + def find_profile!(identifier) + find_profile(identifier) || raise(ActiveRecord::RecordNotFound, "No developer profile matches #{identifier.inspect}") + end + + def find_profile(identifier) + value = identifier.to_s.strip.downcase.delete_prefix("@") + ::Developers::Profile.joins(:user).find_by("LOWER(developers_profiles.handle) = :value OR LOWER(users.email) = :value", value:) + end + + def current_award(definition, profile) + ::Credentials::Award.find_by(definition:, recipient: profile, status: %i[pending_consent active]) + end + + def issuer(definition) + ::Credentials::Issuer.new(definition:, admin: current_user) + end + end + end +end diff --git a/packages/admin_portal/app/views/admin_portal/credentials/awards/batch_new.html.erb b/packages/admin_portal/app/views/admin_portal/credentials/awards/batch_new.html.erb new file mode 100644 index 00000000..0a3927fd --- /dev/null +++ b/packages/admin_portal/app/views/admin_portal/credentials/awards/batch_new.html.erb @@ -0,0 +1,5 @@ +<% content_for :title, "Preview credential batch" %> +
+
<%= link_to "← Credentials", credential_awards_path, class: "text-sm font-semibold text-primary-600 dark:text-primary-400" %>

Preview a credential batch

The preview resolves recipients and detects current credentials. It creates nothing and sends no notifications.

+ <%= form_with url: preview_credential_batch_path, scope: :batch, class: "pu-card" do |form| %>
<%= form.label :definition_id, "Credential", class: "block font-semibold" %><%= form.collection_select :definition_id, @definitions, :id, :name, {}, class: "mt-1 w-full rounded-lg border border-[var(--pu-input-border)] bg-[var(--pu-surface)] px-3 py-2" %>
<%= form.label :recipient_identifiers, "Recipients", class: "block font-semibold" %><%= form.text_area :recipient_identifiers, required: true, rows: 9, placeholder: "One developer handle or account email per line", class: "mt-1 w-full rounded-lg border border-[var(--pu-input-border)] bg-[var(--pu-surface)] px-3 py-2 font-mono" %>
<%= 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.

<%= form.submit "Preview batch", class: "pu-btn pu-btn-md pu-btn-primary" %>
<% end %> +
diff --git a/packages/admin_portal/app/views/admin_portal/credentials/awards/batch_preview.html.erb b/packages/admin_portal/app/views/admin_portal/credentials/awards/batch_preview.html.erb new file mode 100644 index 00000000..6e95d71d --- /dev/null +++ b/packages/admin_portal/app/views/admin_portal/credentials/awards/batch_preview.html.erb @@ -0,0 +1,10 @@ +<% content_for :title, "Credential batch preview" %> +<% issuable = @rows.select { |row| row[:profile] && !row[:existing] } %> +
+
<%= link_to "← Change batch", new_credential_batch_path, class: "text-sm font-semibold text-primary-600 dark:text-primary-400" %>

Batch preview

<%= issuable.size %> of <%= @rows.size %> recipients can receive a new <%= @definition.name %> invitation. Nothing has been created or sent.

+
<% @rows.each do |row| %><% end %>
InputResolved recipientResult
<%= row[:identifier] %><%= row[:profile] ? "#{row[:profile].name} (@#{row[:profile].handle})" : "—" %><% if row[:profile].nil? %>No matching profile<% elsif row[:existing] %><%= row[:existing].status.humanize %> credential exists<% else %>Ready<% end %>
+

Evidence applied to the batch

<%= @evidence_summary %>

+ <% if issuable.any? %> + <%= form_with url: credential_batch_path, class: "text-right" do |form| %><%= hidden_field_tag :definition_id, @definition.id %><%= hidden_field_tag :evidence_summary, @evidence_summary %><% issuable.each do |row| %><%= hidden_field_tag "profile_ids[]", row[:profile].id %><% end %><%= hidden_field_tag :confirm, "issue" %><%= form.submit "Issue #{issuable.size} #{"invitation".pluralize(issuable.size)}", class: "pu-btn pu-btn-md pu-btn-primary", data: {turbo_confirm: "Create #{issuable.size} credential #{"invitation".pluralize(issuable.size)} and send recipient emails?"} %><% end %> + <% end %> +
diff --git a/packages/admin_portal/app/views/admin_portal/credentials/awards/edit.html.erb b/packages/admin_portal/app/views/admin_portal/credentials/awards/edit.html.erb new file mode 100644 index 00000000..3880a644 --- /dev/null +++ b/packages/admin_portal/app/views/admin_portal/credentials/awards/edit.html.erb @@ -0,0 +1,5 @@ +<% content_for :title, "Correct credential" %> +
+
<%= link_to "← Credential", credential_award_path(@award), class: "text-sm font-semibold text-primary-600 dark:text-primary-400" %>

Correct credential details

Every correction requires a reason and is preserved in the audit trail.

+ <%= form_with url: credential_award_path(@award), method: :patch, scope: :credential, class: "pu-card" do |form| %>
<%= form.label :recipient_name, class: "block font-semibold" %><%= form.text_field :recipient_name, value: @award.recipient_name, required: true, class: "mt-1 w-full rounded-lg border border-[var(--pu-input-border)] bg-[var(--pu-surface)] px-3 py-2" %>
<%= form.label :evidence_summary, class: "block font-semibold" %><%= form.text_area :evidence_summary, value: @award.evidence_summary, required: true, rows: 5, class: "mt-1 w-full rounded-lg border border-[var(--pu-input-border)] bg-[var(--pu-surface)] px-3 py-2" %>
<%= form.label :expires_at, class: "block font-semibold" %><%= form.datetime_local_field :expires_at, value: @award.expires_at, class: "mt-1 w-full rounded-lg border border-[var(--pu-input-border)] bg-[var(--pu-surface)] px-3 py-2" %>
<%= label_tag :reason, "Correction reason", class: "block font-semibold" %><%= text_area_tag :reason, nil, required: true, rows: 3, class: "mt-1 w-full rounded-lg border border-[var(--pu-input-border)] bg-[var(--pu-surface)] px-3 py-2" %>
<%= form.submit "Save correction", class: "pu-btn pu-btn-md pu-btn-primary" %>
<% end %> +
diff --git a/packages/admin_portal/app/views/admin_portal/credentials/awards/index.html.erb b/packages/admin_portal/app/views/admin_portal/credentials/awards/index.html.erb new file mode 100644 index 00000000..7627771a --- /dev/null +++ b/packages/admin_portal/app/views/admin_portal/credentials/awards/index.html.erb @@ -0,0 +1,54 @@ +<% content_for :title, "Credentials" %> + +
+
+
+

Credentials

+

Issue verifiable DevCongress credentials and manage their full lifecycle.

+
+
+ <%= link_to "Export audit CSV", export_credential_awards_path, class: "pu-btn pu-btn-md pu-btn-outline" %> + <%= link_to "Preview batch", new_credential_batch_path, class: "pu-btn pu-btn-md pu-btn-outline" %> + <%= link_to "Issue credential", new_credential_award_path, class: "pu-btn pu-btn-md pu-btn-primary" %> +
+
+ + <%= form_with url: credential_awards_path, method: :get, class: "pu-card" do |form| %> +
+
+ <%= form.label :q, "Search", class: "block text-sm font-semibold text-[var(--pu-text)]" %> + <%= form.search_field :q, value: @query, placeholder: "Name, email, handle, or credential ID", class: "mt-1 w-full rounded-lg border border-[var(--pu-input-border)] bg-[var(--pu-surface)] px-3 py-2" %> +
+
+ <%= form.label :status, class: "block text-sm font-semibold text-[var(--pu-text)]" %> + <%= form.select :status, options_for_select([["All statuses", ""], *Credentials::Award.statuses.keys.map { |status| [status.humanize, status] }], @status), {}, class: "mt-1 w-full rounded-lg border border-[var(--pu-input-border)] bg-[var(--pu-surface)] px-3 py-2" %> +
+ <%= form.submit "Filter", class: "pu-btn pu-btn-md pu-btn-outline" %> +
+ <% end %> + +
+ <% if @awards.any? %> +
+ + + + + + <% @awards.each do |award| %> + + + + + + + + <% end %> + +
RecipientCredentialStatusCreated
<%= award.recipient_name %><%= award.recipient.user.email %> · @<%= award.recipient.handle %><%= award.definition.name %><%= award.public_id %>"><%= award.status.humanize %><%= award.created_at.to_date.to_fs(:medium) %><%= link_to "Review →", credential_award_path(award), class: "font-semibold text-primary-600 dark:text-primary-400" %>
+
+ <% else %> +
No credentials match these filters.
+ <% end %> +
+
diff --git a/packages/admin_portal/app/views/admin_portal/credentials/awards/new.html.erb b/packages/admin_portal/app/views/admin_portal/credentials/awards/new.html.erb new file mode 100644 index 00000000..004c296a --- /dev/null +++ b/packages/admin_portal/app/views/admin_portal/credentials/awards/new.html.erb @@ -0,0 +1,46 @@ +<% content_for :title, "Issue credential" %> + +
+
+ <%= link_to "← Credentials", credential_awards_path, class: "text-sm font-semibold text-primary-600 dark:text-primary-400" %> +

Issue one credential

+

This first step is a preview. It does not create a credential or send an email.

+
+ + <% if @definition %> +
+
+
+ <%= image_tag "brand/devcongress-dev-square.png", alt: @definition.badge_alt_text, class: "h-20 w-20 rounded-xl border border-[var(--pu-border)]" %> +

<%= @definition.name %>

<%= @definition.short_description %>

+
+
Review criteria and evidence requirements

Eligibility

<%= simple_format(@definition.eligibility_criteria, class: "mt-1 text-[var(--pu-text-muted)]") %>

Evidence

<%= simple_format(@definition.evidence_requirements, class: "mt-1 text-[var(--pu-text-muted)]") %>
+
+
+ <% end %> + + <%= form_with url: preview_credential_awards_path, scope: :credential, class: "pu-card" do |form| %> +
+
+ <%= form.label :definition_id, "Credential", class: "block font-semibold text-[var(--pu-text)]" %> + <%= form.collection_select :definition_id, @definitions, :id, :name, {selected: @definition&.id}, class: "mt-1 w-full rounded-lg border border-[var(--pu-input-border)] bg-[var(--pu-surface)] px-3 py-2" %> +
+
+ <%= form.label :recipient_identifier, "Recipient", class: "block font-semibold text-[var(--pu-text)]" %> + <%= form.text_field :recipient_identifier, required: true, placeholder: "Developer handle or account email", class: "mt-1 w-full rounded-lg border border-[var(--pu-input-border)] bg-[var(--pu-surface)] px-3 py-2" %> +

The recipient must already have a Connect developer profile.

+
+
+ <%= form.label :evidence_summary, "Evidence reviewed", class: "block font-semibold text-[var(--pu-text)]" %> + <%= form.text_area :evidence_summary, required: true, rows: 5, placeholder: "State the accepted contribution and link or reference the evidence reviewed.", 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 after consent. Include enough to verify the decision, but no private contact details or internal notes.

+
+
+ <%= form.label :expires_at, "Optional expiry", class: "block font-semibold text-[var(--pu-text)]" %> + <%= form.datetime_local_field :expires_at, class: "mt-1 w-full rounded-lg border border-[var(--pu-input-border)] bg-[var(--pu-surface)] px-3 py-2" %> +

The Community Contributor credential does not expire by default.

+
+
+
<%= form.submit "Preview credential", class: "pu-btn pu-btn-md pu-btn-primary" %>
+ <% end %> +
diff --git a/packages/admin_portal/app/views/admin_portal/credentials/awards/preview.html.erb b/packages/admin_portal/app/views/admin_portal/credentials/awards/preview.html.erb new file mode 100644 index 00000000..097457b3 --- /dev/null +++ b/packages/admin_portal/app/views/admin_portal/credentials/awards/preview.html.erb @@ -0,0 +1,37 @@ +<% content_for :title, "Preview credential" %> + +
+
+ <%= link_to "← Change details", new_credential_award_path(definition_id: @definition.id), class: "text-sm font-semibold text-primary-600 dark:text-primary-400" %> +

Preview before invitation

+

No credential exists yet and no notification has been sent.

+
+ +
+
Credential preview
+
+ Pending recipient consent +

<%= @definition.name %>

+

<%= @profile.name %>

+

@<%= @profile.handle %> · <%= @profile.user.email %>

+
+
Criteria version
<%= @definition.criteria_version %>
+
Expiry
<%= @award.expires_at&.to_fs(:long) || "Does not expire" %>
+
Evidence reviewed
<%= @award.evidence_summary %>
+
+
+
+ + <% if @award.errors.any? %> +
<%= @award.errors.full_messages.to_sentence %>
+ <% else %> +

What happens next

  1. The recipient receives an email and sees the invitation in Connect.
  2. Nothing becomes public until the recipient reviews and accepts it.
  3. Acceptance issues the credential, publishes the verification URL, and sends a confirmation email.
+ <%= form_with url: credential_awards_path, scope: :credential, class: "text-right" do |form| %> + <%= form.hidden_field :definition_id, value: @definition.id %> + <%= form.hidden_field :recipient_identifier, value: @profile.handle %> + <%= form.hidden_field :evidence_summary, value: @award.evidence_summary %> + <%= form.hidden_field :expires_at, value: @award.expires_at %> + <%= form.submit "Create invitation", class: "pu-btn pu-btn-md pu-btn-primary", data: {turbo_confirm: "Create this credential invitation and email #{@profile.name}?"} %> + <% end %> + <% end %> +
diff --git a/packages/admin_portal/app/views/admin_portal/credentials/awards/show.html.erb b/packages/admin_portal/app/views/admin_portal/credentials/awards/show.html.erb new file mode 100644 index 00000000..5f692804 --- /dev/null +++ b/packages/admin_portal/app/views/admin_portal/credentials/awards/show.html.erb @@ -0,0 +1,23 @@ +<% content_for :title, @award.definition.name %> + +
+
+
<%= link_to "← Credentials", credential_awards_path, class: "text-sm font-semibold text-primary-600 dark:text-primary-400" %>

<%= @award.definition.name %>

<%= @award.recipient_name %> · <%= @award.public_id %>

+
<%= link_to "Correct details", edit_credential_award_path(@award), class: "pu-btn pu-btn-md pu-btn-outline" %><% if @award.publicly_verifiable? %><%= link_to "Open verification", main_app.public_credential_path(@award.public_id), target: "_blank", rel: "noopener", class: "pu-btn pu-btn-md pu-btn-primary" %><% end %>
+
+ +
Status
"><%= @award.status.humanize %>
Recipient
<%= @award.recipient_name %> · @<%= @award.recipient.handle %>
Issued
<%= @award.issued_at&.to_fs(:long) || "Waiting for consent" %>
Expires
<%= @award.expires_at&.to_fs(:long) || "Does not expire" %>
Evidence
<%= @award.evidence_summary %>
<% if @award.revoked? %>
Revocation reason
<%= @award.revoked_reason %>
<% end %>
+ + <% if @award.active? %> +
+ <%= form_with url: revoke_credential_award_path(@award), class: "pu-card" do |form| %>

Revoke credential

The URL stays resolvable and displays the reason.

<%= form.text_area :reason, required: true, rows: 2, placeholder: "Required reason", class: "w-full rounded-lg border border-[var(--pu-input-border)] bg-[var(--pu-surface)] px-3 py-2" %><%= form.submit "Revoke", class: "pu-btn pu-btn-md pu-btn-danger", data: {turbo_confirm: "Revoke this credential?"} %>
<% end %> + <%= form_with url: expire_credential_award_path(@award), class: "pu-card" do |form| %>

Expire credential

Use for a credential that is no longer current but was valid when issued.

<%= form.text_area :reason, required: true, rows: 2, placeholder: "Required reason", class: "w-full rounded-lg border border-[var(--pu-input-border)] bg-[var(--pu-surface)] px-3 py-2" %><%= form.submit "Mark expired", class: "pu-btn pu-btn-md pu-btn-outline", data: {turbo_confirm: "Mark this credential expired?"} %>
<% end %> +
+ <% end %> + + <% if @award.expired? || @award.revoked? || @award.active? %> + <%= form_with url: reissue_credential_award_path(@award), class: "pu-card" do |form| %>

Reissue

Creates a new consent invitation linked to this record. An active credential is revoked first.

<%= form.text_field :reason, required: true, placeholder: "Reason for reissue", class: "mt-3 w-full rounded-lg border border-[var(--pu-input-border)] bg-[var(--pu-surface)] px-3 py-2" %>
<%= form.submit "Create replacement", class: "pu-btn pu-btn-md pu-btn-outline", data: {turbo_confirm: "Create a replacement credential invitation?"} %>
<% end %> + <% end %> + +

Audit trail

    <% @award.events.each do |event| %>
  1. <%= event.event_type.humanize %>

    <%= event.created_at.to_fs(:long) %> · <%= event.actor_label %>

    <% if event.reason.present? %>

    <%= event.reason %>

    <% end %>
  2. <% end %>
+
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 %>

diff --git a/packages/credentials/app/views/credentials/credential_mailer/revoked.text.erb b/packages/credentials/app/views/credentials/credential_mailer/revoked.text.erb new file mode 100644 index 00000000..753df59e --- /dev/null +++ b/packages/credentials/app/views/credentials/credential_mailer/revoked.text.erb @@ -0,0 +1,8 @@ +Hello <%= @profile.first_name %>, + +Your <%= @definition.name %> credential has been revoked. + +Reason: <%= @award.revoked_reason %> + +View the credential status: +<%= @verification_url %> diff --git a/packages/credentials/db/migrate/20261023090000_create_credentials.rb b/packages/credentials/db/migrate/20261023090000_create_credentials.rb new file mode 100644 index 00000000..92e188e9 --- /dev/null +++ b/packages/credentials/db/migrate/20261023090000_create_credentials.rb @@ -0,0 +1,63 @@ +class CreateCredentials < ActiveRecord::Migration[8.1] + def change + create_table :credentials_definitions do |t| + t.string :name, null: false + t.string :slug, null: false + t.string :short_description, null: false + t.text :description, null: false + t.text :eligibility_criteria, null: false + t.text :evidence_requirements, null: false + t.string :issuing_authority, null: false, default: "DevCongress" + t.json :approver_roles, null: false, default: ["super_admin", "admin"] + t.string :criteria_version, null: false + t.text :lifecycle_rules, null: false + t.text :does_not_prove, null: false + t.string :badge_alt_text, null: false + t.integer :expires_after_months + t.boolean :active, null: false, default: true + + t.timestamps + + t.index :slug, unique: true + end + + create_table :credentials_awards do |t| + t.belongs_to :definition, null: false, foreign_key: {to_table: :credentials_definitions, on_delete: :restrict} + t.belongs_to :recipient, null: false, foreign_key: {to_table: :developers_profiles, on_delete: :restrict} + t.string :public_id, null: false + t.string :recipient_name, null: false + t.text :evidence_summary, null: false + t.string :criteria_version, null: false + t.integer :status, null: false, default: 0 + t.datetime :consented_at + t.datetime :issued_at + t.datetime :expires_at + t.datetime :revoked_at + t.text :revoked_reason + t.references :supersedes, foreign_key: {to_table: :credentials_awards, on_delete: :nullify} + t.references :created_by_admin, null: false, foreign_key: {to_table: :admins, on_delete: :restrict} + t.references :updated_by_admin, foreign_key: {to_table: :admins, on_delete: :nullify} + + t.timestamps + + t.index :public_id, unique: true + t.index [:definition_id, :recipient_id], unique: true, + where: "status IN (0, 1)", name: "index_credentials_awards_on_current_achievement" + t.index [:status, :created_at] + end + + create_table :credentials_events do |t| + t.belongs_to :award, null: false, foreign_key: {to_table: :credentials_awards, on_delete: :cascade} + t.references :admin, foreign_key: {on_delete: :nullify} + t.references :user, foreign_key: {on_delete: :nullify} + t.string :event_type, null: false + t.text :reason + t.json :changeset, null: false, default: {} + + t.timestamps + + t.index [:award_id, :created_at] + t.index [:event_type, :created_at] + end + end +end diff --git a/packages/credentials/lib/engine.rb b/packages/credentials/lib/engine.rb new file mode 100644 index 00000000..17d7c4d5 --- /dev/null +++ b/packages/credentials/lib/engine.rb @@ -0,0 +1,5 @@ +module Credentials + class Engine < Rails::Engine + include Plutonium::Package::Engine + end +end diff --git a/packages/developer_portal/app/controllers/developer_portal/credentials/awards_controller.rb b/packages/developer_portal/app/controllers/developer_portal/credentials/awards_controller.rb new file mode 100644 index 00000000..88be25c6 --- /dev/null +++ b/packages/developer_portal/app/controllers/developer_portal/credentials/awards_controller.rb @@ -0,0 +1,33 @@ +module DeveloperPortal + module Credentials + class AwardsController < DeveloperPortal::PlutoniumController + before_action :set_profile + before_action :set_award, only: %i[show accept] + + def index + @awards = @profile.credential_awards.includes(:definition).newest + end + + def show + end + + def accept + @award.accept!(user: current_user) + ::Credentials::CredentialMailer.with(award: @award).issued.deliver_later + redirect_to main_app.public_credential_path(@award.public_id), notice: "Credential accepted and issued. Your verification page is now public." + rescue ActiveRecord::RecordInvalid => error + redirect_to developers_profile_scoped_credential_award_path(developers_profile_scoped: @profile, id: @award.public_id), alert: error.message + end + + private + + def set_profile + @profile = current_scoped_entity + end + + def set_award + @award = @profile.credential_awards.includes(:definition).find_by!(public_id: params[:id]) + end + end + end +end diff --git a/packages/developer_portal/app/views/developer_portal/credentials/awards/index.html.erb b/packages/developer_portal/app/views/developer_portal/credentials/awards/index.html.erb new file mode 100644 index 00000000..59dfc49a --- /dev/null +++ b/packages/developer_portal/app/views/developer_portal/credentials/awards/index.html.erb @@ -0,0 +1,13 @@ +<% content_for :title, "Credentials" %> +
+

Credentials

Review credentials offered by DevCongress and manage the ones you have accepted.

+ <% if @awards.any? %> + + <% else %> +

No credentials yet

When DevCongress reviews an eligible contribution, its credential invitation will appear here.

+ <% end %> +
diff --git a/packages/developer_portal/app/views/developer_portal/credentials/awards/show.html.erb b/packages/developer_portal/app/views/developer_portal/credentials/awards/show.html.erb new file mode 100644 index 00000000..d07d4b74 --- /dev/null +++ b/packages/developer_portal/app/views/developer_portal/credentials/awards/show.html.erb @@ -0,0 +1,10 @@ +<% content_for :title, @award.definition.name %> +
+
<%= link_to "← Credentials", developers_profile_scoped_credential_awards_path(developers_profile_scoped: @profile), class: "text-sm font-semibold text-primary-600 dark:text-primary-400" %>

<%= @award.definition.name %>

Issued by DevCongress · criteria <%= @award.criteria_version %>

+
<%= @award.status.humanize %>
<%= image_tag(@award.pending_consent? ? "brand/devcongress-dev-square.png" : main_app.credential_badge_path(@award.public_id, format: :svg), alt: @award.definition.badge_alt_text, class: "aspect-square w-full rounded-2xl border-2 border-[var(--dc-ink)]") %>

<%= @award.recipient_name %>

<%= @award.definition.description %>

Eligibility criteria
<%= simple_format(@award.definition.eligibility_criteria) %>
Evidence reviewed
<%= @award.evidence_summary %>
What it does not prove
<%= @award.definition.does_not_prove %>
+ <% if @award.pending_consent? %> +

Your consent

Nothing becomes public until you accept.

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?"} %>
+ <% else %> +
<%= link_to "Open public verification page", main_app.public_credential_path(@award.public_id), class: "pu-btn pu-btn-md pu-btn-primary" %>
+ <% end %> +
diff --git a/packages/developer_portal/app/views/plutonium/_resource_sidebar.html.erb b/packages/developer_portal/app/views/plutonium/_resource_sidebar.html.erb index 5af8ece9..f60d150a 100644 --- a/packages/developer_portal/app/views/plutonium/_resource_sidebar.html.erb +++ b/packages/developer_portal/app/views/plutonium/_resource_sidebar.html.erb @@ -6,6 +6,7 @@ m.item "Skills", url: resource_url_for(Developers::ProfileSkill, parent: nil), icon: Phlex::TablerIcons::Tools m.item "Projects", url: resource_url_for(Showcase::Project, parent: nil), icon: Phlex::TablerIcons::Stack2 m.item "Credits", url: resource_url_for(Showcase::ProjectContributor, parent: nil), icon: Phlex::TablerIcons::UsersGroup + m.item "Credentials", url: developers_profile_scoped_credential_awards_path(developers_profile_scoped: current_scoped_entity), icon: Phlex::TablerIcons::Certificate m.item "Network", url: developers_profile_scoped_network_path(developers_profile_scoped: current_scoped_entity), icon: Phlex::TablerIcons::Affiliate m.item "Jobs & gigs", url: resource_url_for(Hiring::JobPost, parent: nil), icon: Phlex::TablerIcons::Search m.item "My applications", url: resource_url_for(Hiring::JobApplication, parent: nil), icon: Phlex::TablerIcons::Send diff --git a/packages/developer_portal/config/routes.rb b/packages/developer_portal/config/routes.rb index 4ab5c953..219186af 100644 --- a/packages/developer_portal/config/routes.rb +++ b/packages/developer_portal/config/routes.rb @@ -12,6 +12,11 @@ # Followers, following, connections and suggestions. get "network", to: "network#index", as: :network + get "credentials", to: "credentials/awards#index", as: :credential_awards + get "credentials/:id", to: "credentials/awards#show", as: :credential_award, + constraints: {id: /[1-9A-HJ-NP-Za-km-z]{24}/} + post "credentials/:id/accept", to: "credentials/awards#accept", as: :accept_credential_award, + constraints: {id: /[1-9A-HJ-NP-Za-km-z]{24}/} # register resources above. diff --git a/packages/developers/app/models/developers/profile.rb b/packages/developers/app/models/developers/profile.rb index dc7ecd57..0a1288fa 100644 --- a/packages/developers/app/models/developers/profile.rb +++ b/packages/developers/app/models/developers/profile.rb @@ -77,6 +77,7 @@ class Developers::Profile < Developers::ResourceRecord has_many :profile_skills, class_name: "Developers::ProfileSkill", dependent: :destroy has_many :skills, through: :profile_skills has_many :job_applications, class_name: "Hiring::JobApplication", dependent: :destroy + has_many :credential_awards, class_name: "Credentials::Award", foreign_key: :recipient_id, inverse_of: :recipient, dependent: :restrict_with_error # Network: follows in both directions. Two people who follow each other are # connected (see #connections). diff --git a/test/integration/credentials/credential_workflow_test.rb b/test/integration/credentials/credential_workflow_test.rb new file mode 100644 index 00000000..9cce1dc9 --- /dev/null +++ b/test/integration/credentials/credential_workflow_test.rb @@ -0,0 +1,95 @@ +require "test_helper" + +class CredentialWorkflowTest < ActionDispatch::IntegrationTest + include Plutonium::Testing::AuthHelpers + include AccountsTestHelper + include ActionMailer::TestHelper + + setup do + @definition = create_credential_definition! + @profile = create_profile!(name: "Ama Mensah") + @admin = Admin.create!(email: "admin@example.com", status: :verified, password_hash: BCrypt::Password.create("password123")) + end + + test "admin preview is a dry run and creation sends one consent invitation" do + login_as(@admin, portal: :admin) + + assert_no_difference("Credentials::Award.count") do + assert_no_enqueued_emails do + post "/admin/credentials/preview", params: {credential: { + definition_id: @definition.id, + recipient_identifier: @profile.handle, + evidence_summary: "Reviewed program contribution DC-44" + }} + end + end + assert_response :success + assert_includes response.body, "No credential exists yet" + + assert_difference("Credentials::Award.count", 1) do + assert_enqueued_emails 1 do + post "/admin/credentials", params: {credential: { + definition_id: @definition.id, + recipient_identifier: @profile.handle, + evidence_summary: "Reviewed program contribution DC-44" + }} + end + end + assert_redirected_to %r{/admin/credentials/} + + assert_no_difference("Credentials::Award.count") do + assert_no_enqueued_emails do + post "/admin/credentials", params: {credential: { + definition_id: @definition.id, + recipient_identifier: @profile.handle, + evidence_summary: "Reviewed program contribution DC-44" + }} + end + end + end + + test "recipient sees invitation and explicitly consents before publication" do + award = Credentials::Issuer.new(definition: @definition, admin: @admin).issue!( + profile: @profile, + evidence_summary: "Reviewed project contribution", + notify: false + ).award + login_user(@profile.user) + + get "/developer/#{@profile.handle}/credentials/#{award.public_id}" + assert_response :success + assert_includes response.body, "Nothing becomes public" + + assert_enqueued_emails 1 do + post "/developer/#{@profile.handle}/credentials/#{award.public_id}/accept" + end + assert_redirected_to "/credentials/#{award.public_id}" + assert award.reload.active? + end + + test "batch preview creates no credentials and sends no notifications" do + login_as(@admin, portal: :admin) + + assert_no_difference("Credentials::Award.count") do + assert_no_enqueued_emails do + post "/admin/credentials/batch/preview", params: {batch: { + definition_id: @definition.id, + recipient_identifiers: "#{@profile.handle}\nmissing@example.com", + evidence_summary: "Reviewed cohort contribution records" + }} + end + end + + assert_response :success + assert_includes response.body, "1 of 2 recipients" + assert_includes response.body, "No matching profile" + end + + test "unauthenticated users cannot access credential administration" do + get "/admin/credentials" + assert_response :redirect + + get "/developer/#{@profile.handle}/credentials" + assert_response :redirect + end +end diff --git a/test/jobs/credentials/expiry_job_test.rb b/test/jobs/credentials/expiry_job_test.rb new file mode 100644 index 00000000..5ecd55b8 --- /dev/null +++ b/test/jobs/credentials/expiry_job_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class CredentialsExpiryJobTest < ActiveJob::TestCase + include AccountsTestHelper + + test "expires active credentials after their configured expiry" do + due = create_credential_award!(status: :active, expires_at: 1.minute.ago) + future = create_credential_award!(status: :active, expires_at: 1.day.from_now) + + Credentials::ExpiryJob.perform_now + + assert due.reload.expired? + assert future.reload.active? + assert_equal "Credential reached its configured expiry date", due.events.last.reason + end +end diff --git a/test/models/credentials/award_test.rb b/test/models/credentials/award_test.rb new file mode 100644 index 00000000..c8eebb40 --- /dev/null +++ b/test/models/credentials/award_test.rb @@ -0,0 +1,101 @@ +require "test_helper" + +class CredentialsAwardTest < ActiveSupport::TestCase + include AccountsTestHelper + include ActionMailer::TestHelper + + setup do + @profile = create_profile! + @definition = create_credential_definition! + @admin = Admin.create!(email: "admin@example.com", status: :verified) + @issuer = Credentials::Issuer.new(definition: @definition, admin: @admin) + end + + test "issuance is idempotent for a current achievement" do + assert_enqueued_emails 1 do + first = @issuer.issue!(profile: @profile, evidence_summary: "Reviewed contribution DC-1") + duplicate = @issuer.issue!(profile: @profile, evidence_summary: "Reviewed contribution DC-1") + + assert first.created + assert_not duplicate.created + assert_equal first.award, duplicate.award + end + + assert_equal 1, Credentials::Award.count + assert_match(/\A[1-9A-HJ-NP-Za-km-z]{24}\z/, Credentials::Award.first.public_id) + end + + test "issuance requires evidence that supports the credential" do + error = assert_raises(ActiveRecord::RecordInvalid) do + @issuer.issue!(profile: @profile, evidence_summary: "", notify: false) + end + + assert_includes error.record.errors[:evidence_summary], "can't be blank" + assert_empty Credentials::Award.where(recipient: @profile) + end + + test "issuance enforces the definition's approver roles" do + restricted_definition = create_credential_definition!(approver_roles: %w[super_admin]) + regular_admin = Admin.create!(email: "reviewer@example.com", status: :verified, role: :admin) + + error = assert_raises(ArgumentError) do + Credentials::Issuer.new(definition: restricted_definition, admin: regular_admin) + end + + assert_match(/cannot approve/, error.message) + end + + test "only the recipient can consent and issue the credential" do + award = @issuer.issue!(profile: @profile, evidence_summary: "Reviewed contribution DC-2", notify: false).award + + assert_raises(ActiveRecord::RecordNotFound) { award.accept!(user: create_user!) } + award.accept!(user: @profile.user) + + assert award.active? + assert award.consented_at + assert award.issued_at + assert_equal %w[invitation_created consent_recorded issued], award.events.pluck(:event_type) + end + + test "duplicate active credentials are prevented at model and database levels" do + award = @issuer.issue!(profile: @profile, evidence_summary: "Reviewed contribution DC-3", notify: false).award + award.accept!(user: @profile.user) + + duplicate = Credentials::Award.new( + definition: @definition, + recipient: @profile, + evidence_summary: "Duplicate", + created_by_admin: @admin, + status: :active, + consented_at: Time.current, + issued_at: Time.current + ) + + assert_not duplicate.valid? + assert_includes duplicate.errors[:recipient_id], "already has a current credential for this achievement" + end + + test "correction revocation expiry and reissue are auditable" do + award = create_credential_award!(profile: @profile, definition: @definition, admin: @admin, status: :active) + award.record_event!("issued", admin: @admin) + + award.correct!({recipient_name: "Ada B. Lovelace"}, admin: @admin, reason: "Correct legal display name") + award.revoke!(admin: @admin, reason: "Evidence was withdrawn") + replacement = @issuer.issue!(profile: @profile, evidence_summary: award.evidence_summary, supersedes: award, notify: false).award + award.record_event!("reissued", admin: @admin, reason: "Issue corrected record") + + assert award.revoked? + assert_equal award, replacement.supersedes + assert_equal %w[issued corrected revoked reissued], award.events.pluck(:event_type) + assert_equal "Ada B. Lovelace", award.recipient_name + end + + test "an active credential can be expired with a reason" do + award = create_credential_award!(profile: @profile, definition: @definition, admin: @admin, status: :active) + award.expire!(admin: @admin, reason: "Time-limited program ended") + + assert award.expired? + assert award.expires_at + assert_equal "Time-limited program ended", award.events.last.reason + end +end diff --git a/test/support/accounts.rb b/test/support/accounts.rb index e8753d33..c4c7e6ea 100644 --- a/test/support/accounts.rb +++ b/test/support/accounts.rb @@ -14,6 +14,35 @@ def create_company!(owner: nil, name: "Company #{SecureRandom.hex(3)}") end end + def create_credential_definition!(**attributes) + Credentials::Definition.create!({ + name: "DevCongress Community Contributor", + slug: "community-contributor-#{SecureRandom.hex(3)}", + short_description: "Recognizes a reviewed contribution to the DevCongress community.", + description: "Awarded after DevCongress reviews a completed contribution with a concrete community outcome.", + eligibility_criteria: "Complete an accepted contribution with a concrete outcome for a DevCongress program, event, project, or resource.", + evidence_requirements: "A first-party project, event, program, or organizer record reviewed by an authorized administrator.", + issuing_authority: "DevCongress", + approver_roles: %w[super_admin admin], + criteria_version: "1.0", + lifecycle_rules: "Does not expire. Corrections, revocations, and reissues require a recorded reason.", + does_not_prove: "This is not employment, a degree, professional licensure, identity verification, or LinkedIn endorsement.", + badge_alt_text: "DevCongress Community Contributor credential badge" + }.merge(attributes)) + end + + def create_credential_award!(profile: create_profile!, definition: create_credential_definition!, admin: nil, status: :pending_consent, **attributes) + admin ||= Admin.create!(email: "admin-#{SecureRandom.hex(3)}@example.com", status: :verified) + timestamps = (status.to_sym == :active) ? {consented_at: Time.current, issued_at: Time.current} : {} + Credentials::Award.create!({ + definition:, + recipient: profile, + evidence_summary: "Reviewed contribution record DC-123.", + created_by_admin: admin, + status: + }.merge(timestamps).merge(attributes)) + end + # Published jobs are approved too unless review: true (first-job review). def create_job!(company:, published: true, review: false, **attributes) job = company.job_posts.create!(title: "Backend Engineer", description: "Build things.", **attributes) From 0c90fda5977d13b0823babc4a0f159d1c5c8cdcf Mon Sep 17 00:00:00 2001 From: Yaw Boakye at Zebo Date: Fri, 23 Oct 2026 13:21:48 +0100 Subject: [PATCH 2/2] add public credential verification pages --- .../site/credentials_controller.rb | 25 ++++ app/views/site/credentials/badge.svg.erb | 15 +++ .../site/credentials/certificate.svg.erb | 24 ++++ app/views/site/credentials/show.html.erb | 108 ++++++++++++++++++ config/routes.rb | 6 + .../credentials/public_credentials_test.rb | 66 +++++++++++ 6 files changed, 244 insertions(+) create mode 100644 app/controllers/site/credentials_controller.rb create mode 100644 app/views/site/credentials/badge.svg.erb create mode 100644 app/views/site/credentials/certificate.svg.erb create mode 100644 app/views/site/credentials/show.html.erb create mode 100644 test/integration/credentials/public_credentials_test.rb diff --git a/app/controllers/site/credentials_controller.rb b/app/controllers/site/credentials_controller.rb new file mode 100644 index 00000000..ec488710 --- /dev/null +++ b/app/controllers/site/credentials_controller.rb @@ -0,0 +1,25 @@ +module Site + class CredentialsController < BaseController + before_action :set_award + + def show + end + + def badge + @theme = (params[:theme] == "dark") ? "dark" : "light" + response.headers["Content-Disposition"] = %(inline; filename="#{@award.definition.slug}-badge-#{@theme}.svg") + render formats: :svg, content_type: "image/svg+xml" + end + + def certificate + response.headers["Content-Disposition"] = %(attachment; filename="#{@award.definition.slug}-#{@award.public_id}.svg") + render formats: :svg, content_type: "image/svg+xml" + end + + private + + def set_award + @award = ::Credentials::Award.publicly_verifiable.includes(:definition, recipient: :user).find_by!(public_id: params[:id]) + end + end +end diff --git a/app/views/site/credentials/badge.svg.erb b/app/views/site/credentials/badge.svg.erb new file mode 100644 index 00000000..1e6362a7 --- /dev/null +++ b/app/views/site/credentials/badge.svg.erb @@ -0,0 +1,15 @@ + + + <%= @award.definition.badge_alt_text %> + A DevCongress credential badge for <%= @award.definition.name %>. + <% dark = @theme == "dark" %> + "/> + " stroke-width="28"/> + " stroke-width="24"/> + d:c + ">DEVCONGRESS + " stroke-width="12"/> + ">COMMUNITY + ">CONTRIBUTOR + "><%= @award.public_id %> + diff --git a/app/views/site/credentials/certificate.svg.erb b/app/views/site/credentials/certificate.svg.erb new file mode 100644 index 00000000..0d930f59 --- /dev/null +++ b/app/views/site/credentials/certificate.svg.erb @@ -0,0 +1,24 @@ + + + <%= @award.definition.name %> certificate for <%= @award.recipient_name %> + A printable DevCongress certificate with recipient, achievement, issue date, and verification identifier. + + + + + dev:congress{}; + VERIFIED CREDENTIAL + DEVCONGRESS RECOGNIZES + <%= @award.recipient_name %> + + for earning the + <%= @award.definition.name %> + +
<%= @award.definition.short_description %>
+
+ ISSUED + <%= @award.issued_at.to_date.to_fs(:long) %> + CREDENTIAL ID + <%= @award.public_id %> + Verify at <%= public_credential_url(@award.public_id) %> +
diff --git a/app/views/site/credentials/show.html.erb b/app/views/site/credentials/show.html.erb new file mode 100644 index 00000000..d911989e --- /dev/null +++ b/app/views/site/credentials/show.html.erb @@ -0,0 +1,108 @@ +<% award = @award %> +<% definition = award.definition %> +<% verification_url = public_credential_url(award.public_id) %> +<% content_for :title, "#{definition.name} · #{award.recipient_name}" %> +<% content_for :description, "Verify #{award.recipient_name}'s #{definition.name}, issued by DevCongress." %> +<% structured_data({ + "@context" => "https://schema.org", + "@type" => "EducationalOccupationalCredential", + "name" => definition.name, + "description" => definition.description, + "credentialCategory" => "Certificate", + "recognizedBy" => {"@type" => "Organization", "name" => "DevCongress", "url" => "https://devcongress.org/"}, + "about" => {"@type" => "Person", "name" => award.recipient_name}, + "dateCreated" => award.issued_at&.to_date&.iso8601, + "expires" => award.expires_at&.to_date&.iso8601, + "identifier" => award.public_id, + "url" => verification_url + }.compact) %> + +
+
+
+ <%= image_tag credential_badge_path(award.public_id, format: :svg), alt: definition.badge_alt_text, + class: "aspect-square w-full max-w-[220px] rounded-2xl border-2 border-[#111]" %> +
+
+
+ Verified DevCongress credential + <% status_classes = {"active" => "bg-emerald-100 text-emerald-800", "expired" => "bg-amber-100 text-amber-900", "revoked" => "bg-red-100 text-red-800"} %> + <%= award.status %> +
+

<%= definition.name %>

+

Awarded to <%= award.recipient_name %>

+

<%= definition.short_description %>

+ +
+
Issued by
DevCongress
+
Issue date
<%= award.issued_at.to_date.to_fs(:long) %>
+ <% if award.expires_at %>
Expires
<%= award.expires_at.to_date.to_fs(:long) %>
<% end %> +
Credential ID
<%= award.public_id %>
+
+
+
+ + <% unless award.active? %> +
p-6" role="status"> +

This credential is <%= award.status %>.

+ <% if award.revoked? %>

<%= award.revoked_reason %>

<% end %> +

This verification URL remains available so a previously shared credential never becomes ambiguous.

+
+ <% end %> + +
+
+
+

What this credential recognizes

+

<%= definition.description %>

+
+ +
+

Criteria and evidence

+

Eligibility criteria

+
<%= simple_format(definition.eligibility_criteria) %>
+

Evidence reviewed

+

<%= award.evidence_summary %>

+

Assessed against criteria version <%= award.criteria_version %>.

+
+ +
+

What it does not prove

+

<%= definition.does_not_prove %>

+
+
+ + +
+
diff --git a/config/routes.rb b/config/routes.rb index 3a45a67f..ad635f3c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -35,6 +35,12 @@ get "companies/:slug", to: "companies#show", as: :public_company get "projects", to: "projects#index", as: :public_projects get "projects/:slug", to: "projects#show", as: :public_project + get "credentials/:id", to: "credentials#show", as: :public_credential, + constraints: {id: /[1-9A-HJ-NP-Za-km-z]{24}/} + get "credentials/:id/badge", to: "credentials#badge", as: :credential_badge, + defaults: {format: :svg}, constraints: {id: /[1-9A-HJ-NP-Za-km-z]{24}/} + get "credentials/:id/certificate", to: "credentials#certificate", as: :credential_certificate, + defaults: {format: :svg}, constraints: {id: /[1-9A-HJ-NP-Za-km-z]{24}/} # Unsubscribe links in emails (no sign-in; the signed token names the user). get "unsubscribe/:token", to: "unsubscribes#show", as: :unsubscribe, format: false diff --git a/test/integration/credentials/public_credentials_test.rb b/test/integration/credentials/public_credentials_test.rb new file mode 100644 index 00000000..70589335 --- /dev/null +++ b/test/integration/credentials/public_credentials_test.rb @@ -0,0 +1,66 @@ +require "test_helper" + +class PublicCredentialsTest < ActionDispatch::IntegrationTest + include AccountsTestHelper + + setup do + @definition = create_credential_definition!(slug: "community-contributor") + @profile = create_profile!(name: "Ama Mensah") + @admin = Admin.create!(email: "admin@example.com", status: :verified) + end + + test "pending credentials are private" do + award = create_credential_award!(profile: @profile, definition: @definition, admin: @admin) + + get "/credentials/#{award.public_id}" + assert_response :not_found + get "/credentials/#{award.public_id}/badge.svg" + assert_response :not_found + end + + test "active credential publishes minimum verification details and LinkedIn instructions" do + award = create_credential_award!(profile: @profile, definition: @definition, admin: @admin, status: :active) + + get "/credentials/#{award.public_id}" + + assert_response :success + assert_select "h1", @definition.name + assert_includes response.body, "Ama Mensah" + assert_includes response.body, award.public_id + assert_includes response.body, "Add to LinkedIn" + assert_select "a[href=?]", Credentials::Award::LINKEDIN_ADD_TO_PROFILE_URL + assert_not_includes response.body, @profile.user.email + assert_includes response.body, "does not create a LinkedIn verification badge" + assert_includes response.body, "EducationalOccupationalCredential" + end + + test "revoked and expired credentials stay resolvable with their status" do + revoked = create_credential_award!(profile: @profile, definition: @definition, admin: @admin, status: :active) + revoked.revoke!(admin: @admin, reason: "Evidence invalidated") + expired = create_credential_award!(profile: create_profile!(name: "Kojo Owusu"), definition: @definition, admin: @admin, status: :active) + expired.expire!(admin: @admin, reason: "Program ended") + + get "/credentials/#{revoked.public_id}" + assert_response :success + assert_includes response.body, "Evidence invalidated" + + get "/credentials/#{expired.public_id}" + assert_response :success + assert_includes response.body, "expired" + end + + test "badge and certificate are downloadable SVG assets" do + award = create_credential_award!(profile: @profile, definition: @definition, admin: @admin, status: :active) + + get "/credentials/#{award.public_id}/badge.svg" + assert_response :success + assert_equal "image/svg+xml", response.media_type + assert_includes response.body, @definition.badge_alt_text + + get "/credentials/#{award.public_id}/certificate.svg" + assert_response :success + assert_equal "image/svg+xml", response.media_type + assert_includes response.headers["Content-Disposition"], "attachment" + assert_includes response.body, @profile.name + end +end