Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
* **Manage lifecycle** — move events from draft through CFP, review, agenda approval, publishing, schedule changes, completion, and archival.
* **Ingest & publish sessions** — preview/import Sessionize sessions, normalize speaker metadata, and publish deterministic GitHub event artifacts.
* **Curate (Attendees)** — *Curator Agent* helps select attendees fairly when registrations exceed capacity (often **3×**), balancing **theme suitability** and **DEI**; outputs are explainable **recommendations**, never auto‑rejections.
* **Build Community Passport** — unify member identity badges, participation timeline entries, and privacy controls for reusable growth signals.
* **Mentor growth surfaces** — allow mentor opt‑in, discovery, and organizer-reviewed mentor-pairing recommendation drafts.
* **Run** — *Facilitator Agent* suggests prompts, Q\&A, and captures notes (organizer‑controlled).
* **Report** — *Reporter Agent* drafts summaries, highlights, and action items.
* **Approve** — **Human‑in‑the‑loop** diffs, approvals, and a full audit trail.
Expand Down Expand Up @@ -170,7 +172,7 @@ Accessible, headless primitives for a high-performance shadcn/ui inspired fronte
│ │ │ └─ Auth/ # DevelopmentAuthStateProvider, ClaimsCurrentUserService
│ │ ├─ Bethuya.Hybrid.Web.Client/ # Blazor WebAssembly client
│ │ └─ Bethuya.Hybrid.Shared/ # Shared Razor components, Auth (roles, policies, UserInfo)
│ ├─ Hackmum.Bethuya.Core/ # Domain: Events, Registrations, Decisions, FairnessBudget
│ ├─ Hackmum.Bethuya.Core/ # Domain: Events, Registrations, Decisions, Community Passport, Mentorship
│ ├─ Hackmum.Bethuya.Agents/ # Planner, Curator, Facilitator, Reporter agents
│ ├─ Hackmum.Bethuya.AI/ # Provider router (Foundry/Ollama/Azure/OpenAI), prompts, memory
│ ├─ Hackmum.Bethuya.Backend/ # Minimal API (Aspire-connected, Refit-ready)
Expand Down Expand Up @@ -238,6 +240,23 @@ Summary, highlights, action items → human edits → publish (attribution).

***

## ✅ Foundation slices delivered (PR2 → PR7)

These stacked PRs established the current platform backbone and are now reflected in `main` behavior:

| Slice | Foundation added |
| :--- | :--- |
| PR2 | **Community Passport** core (`CommunityMember`, linked identities, privacy + residency metadata) |
| PR3 | **Participation Ledger** ingestion and timeline projection foundations |
| PR4 | **Opportunity signals** and community growth read-model primitives |
| PR5 | **Journey projections** and organizer dashboard read models |
| PR6 | **Recommendation envelope + audit** foundation (human-approval required drafts via `Decision`) |
| PR7 | **Mentor growth surfaces**: mentor opt-in, discovery, and policy-bound mentor-pairing recommendation draft endpoint |

Key rule across all slices: AI can draft recommendations, but publication or operational action remains human-approved.

***

## 📅 Event Management Lifecycle

Bethuya's event model now acts as the operational source of truth, not just a creation form. Organizers can save lightweight drafts, publish-ready events, and lifecycle transitions through the Backend API and Blazor UI.
Expand Down Expand Up @@ -689,10 +708,12 @@ This section documents the current Curation Intelligence interaction model so fu

* [x] Event model & storage
* [x] Event lifecycle, Sessionize ingestion, GitHub artifact publishing, and cover-image upload handling
* [ ] Planner + **Curator (attendees)** agents
* [ ] Diff + approval workflow (HIL)
* [ ] Reporter draft
* [ ] Aspire AppHost + telemetry
* [x] Community Passport + participation ledger foundations
* [x] Recommendation envelopes + auditable HIL draft/approval backbone
* [x] Mentor opt-in/discovery + organizer-gated pairing recommendation surface
* [ ] Planner + **Curator (attendees)** full production policy packs
* [ ] Reporter publishing workflow
* [ ] Extended AppHost operational hardening + cloud deployment packs

### Post‑Hackathon (Backbone Goals)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,20 +330,21 @@ public sealed record LeadershipFunnelReadModelDto(
/// Request payload for drafting member-growth opportunity recommendations.
/// </summary>
public sealed record DraftMemberGrowthRecommendationDto(
int LookbackDays = 90);
int LookbackDays = 90,
string? RequestedBy = null);

/// <summary>
/// Request payload for drafting weekly community briefings.
/// </summary>
public sealed record DraftWeeklyCommunityBriefingDto(
int LookbackDays = 90);
int LookbackDays = 90,
string? RequestedBy = null);

/// <summary>
/// Request payload for approving a recommendation draft.
/// ApprovedBy is always derived from the authenticated principal server-side;
/// only optional notes are accepted from the caller.
/// </summary>
public sealed record ApproveRecommendationDraftDto(
string ApprovedBy,
string? ApprovalNotes = null);

/// <summary>
Expand Down
49 changes: 49 additions & 0 deletions src/Hackmum.Bethuya.Backend/Contracts/MentorshipContracts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Hackmum.Bethuya.Core.Enums;

namespace Hackmum.Bethuya.Backend.Contracts;

// ─── Opt-in ───────────────────────────────────────────────────────────────────

/// <summary>Request to opt in or update an existing mentor profile.</summary>
public sealed record MentorOptInRequest(
IReadOnlyList<MentorExpertiseArea> ExpertiseAreas,
string? IntroductionBio = null,
int AvailabilityHoursPerMonth = 2,
bool IsDiscoverable = true);

/// <summary>Request to pause or withdraw from the mentorship programme.</summary>
public sealed record MentorStatusUpdateRequest(
MentorshipStatus Status);

// ─── Responses ────────────────────────────────────────────────────────────────

/// <summary>Public surface returned from all mentor profile reads.</summary>
public sealed record MentorProfileResponse(
Guid MentorProfileId,
string MemberDisplayName,
string MemberEmail,
MentorshipStatus Status,
IReadOnlyList<MentorExpertiseArea> ExpertiseAreas,
string? IntroductionBio,
int AvailabilityHoursPerMonth,
bool IsDiscoverable,
DateTimeOffset OptedInAt,
DateTimeOffset UpdatedAt);

/// <summary>Trimmed public entry returned from the community discovery directory (no email).</summary>
public sealed record MentorDiscoveryEntryResponse(
Guid MentorProfileId,
string DisplayName,
string? OccupationStatus,
string? CompanyName,
IReadOnlyList<MentorExpertiseArea> ExpertiseAreas,
string? IntroductionBio,
int AvailabilityHoursPerMonth);

// ─── Recommendation ────────────────────────────────────────────────────────────

/// <summary>Organizer-scoped request to generate a mentor-pairing recommendation draft.</summary>
public sealed record DraftMentorPairingSuggestionRequest(
int LookbackDays = 90,
IReadOnlyList<MentorExpertiseArea>? FocusAreas = null,
string? RequestedBy = null);
11 changes: 5 additions & 6 deletions src/Hackmum.Bethuya.Backend/Contracts/RecommendationContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@
namespace Hackmum.Bethuya.Backend.Contracts;

public sealed record DraftMemberGrowthRecommendationRequest(
int LookbackDays = 90);
int LookbackDays = 90,
string? RequestedBy = null);

public sealed record DraftWeeklyCommunityBriefingRequest(
int LookbackDays = 90);
int LookbackDays = 90,
string? RequestedBy = null);

/// <summary>
/// Approval request DTO. ApprovedBy is always derived from the authenticated principal;
/// only optional notes are accepted from the caller.
/// </summary>
public sealed record ApproveRecommendationDraftRequest(
string ApprovedBy,
string? ApprovalNotes = null);

public sealed record RecommendationAuditMetadataResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,7 @@ public static void MapCommunityPassportEndpoints(this WebApplication app)
{
var approved = await service.ApproveDraftAsync(
draftId,
approver,
request,
new ApproveRecommendationDraftRequest(approver, request.ApprovalNotes),
ct);
return Results.Ok(approved);
}
Expand Down
182 changes: 182 additions & 0 deletions src/Hackmum.Bethuya.Backend/Endpoints/MentorshipEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
using System.Collections.Generic;
using System.Security.Claims;
using Hackmum.Bethuya.Backend.Contracts;
using Hackmum.Bethuya.Backend.Services;
using Hackmum.Bethuya.Core.Enums;
using Microsoft.AspNetCore.Mvc;
using ServiceDefaults.Auth;

namespace Hackmum.Bethuya.Backend.Endpoints;

/// <summary>
/// Mentorship programme endpoint mappings.
/// Opt-in and discovery require <see cref="BethuyaPolicyNames.RequireAttendee"/>;
/// recommendation drafts require <see cref="BethuyaPolicyNames.RequireOrganizer"/>.
/// </summary>
public static class MentorshipEndpoints
{
/// <summary>Maps mentor opt-in, status update, profile read, discovery, and recommendation endpoints.</summary>
public static void MapMentorshipEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/community/mentorship")
.WithTags("Mentorship")
.RequireAuthorization(BethuyaPolicyNames.RequireAttendee);

// ── Opt-in (any authenticated member) ───────────────────────────────

group.MapPost("/opt-in", async (
MentorOptInRequest request,
ClaimsPrincipal user,
[FromServices] MentorshipService service,
CancellationToken ct) =>
{
if (request.ExpertiseAreas is null || request.ExpertiseAreas.Count == 0)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["expertiseAreas"] = ["At least one expertise area is required to opt in."]
});
}

var subject = GetSubject(user);
if (subject is null)
{
return Results.Unauthorized();
}

var profile = await service.OptInAsync(subject, request, ct);
return Results.Ok(profile);
});

group.MapPatch("/status", async (
MentorStatusUpdateRequest request,
ClaimsPrincipal user,
[FromServices] MentorshipService service,
CancellationToken ct) =>
{
if (!Enum.IsDefined(request.Status))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["status"] = ["Status must be a valid MentorshipStatus value."]
});
}

var subject = GetSubject(user);
if (subject is null)
{
return Results.Unauthorized();
}

try
{
var updated = await service.UpdateStatusAsync(subject, request, ct);
return Results.Ok(updated);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(ex.Message);
}
});

group.MapGet("/my-profile", async (
ClaimsPrincipal user,
[FromServices] MentorshipService service,
CancellationToken ct) =>
{
var subject = GetSubject(user);
if (subject is null)
{
return Results.Unauthorized();
}

var profile = await service.GetMyProfileAsync(subject, ct);
return profile is null ? Results.NotFound("You have not opted in as a mentor.") : Results.Ok(profile);
});

// ── Discovery (any authenticated member) ─────────────────────────────

group.MapGet("/discover", async (
[FromQuery] string? expertiseAreas,
int? limit,
[FromServices] MentorshipService service,
CancellationToken ct) =>
{
if (limit is <= 0 or > 100)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["limit"] = ["Limit must be between 1 and 100."]
});
}

var filterAreas = ParseExpertiseAreas(expertiseAreas);
var results = await service.DiscoverMentorsAsync(filterAreas, limit ?? 20, ct);
return Results.Ok(results);
});

// ── Recommendation draft (organizer only) ─────────────────────────────

group.MapPost("/recommendations/mentor-pairing", async (
DraftMentorPairingSuggestionRequest request,
ClaimsPrincipal user,
[FromServices] MentorshipService service,
CancellationToken ct) =>
{
if (request.LookbackDays is < 30 or > 365)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["lookbackDays"] = ["Lookback days must be between 30 and 365."]
});
}

var subject = GetSubject(user);
if (subject is null)
{
return Results.Unauthorized();
}

var requestedBy = subject.Email ?? subject.DisplayName ?? subject.UserId;
var draft = await service.DraftMentorPairingSuggestionAsync(request, requestedBy, ct);
return Results.Ok(draft);
})
.RequireAuthorization(BethuyaPolicyNames.RequireOrganizer);
}

private static CommunitySubjectContext? GetSubject(ClaimsPrincipal user)
{
var userId = user.FindFirst("sub")?.Value
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;

if (string.IsNullOrWhiteSpace(userId))
{
return null;
}

var displayName = user.FindFirst("name")?.Value ?? user.Identity?.Name;
var email = user.FindFirst(ClaimTypes.Email)?.Value ?? user.FindFirst("email")?.Value;

return new CommunitySubjectContext(userId, displayName, email);
}

private static List<MentorExpertiseArea>? ParseExpertiseAreas(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return null;
}

var parsed = new List<MentorExpertiseArea>();
foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (Enum.TryParse<MentorExpertiseArea>(part, ignoreCase: true, out var area)
&& Enum.IsDefined(area))
{
parsed.Add(area);
}
}

return parsed.Count > 0 ? parsed : null;
}
}
5 changes: 5 additions & 0 deletions src/Hackmum.Bethuya.Backend/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
using Hackmum.Bethuya.Backend;
using Hackmum.Bethuya.Backend.Endpoints;
using Hackmum.Bethuya.Backend.Services;
using Hackmum.Bethuya.Core.Repositories;
using Hackmum.Bethuya.Core.Services;
using Hackmum.Bethuya.Infrastructure.Data;
using Hackmum.Bethuya.Infrastructure.Extensions;
using Hackmum.Bethuya.Infrastructure.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
Expand Down Expand Up @@ -54,6 +56,8 @@
builder.Services.AddScoped<CommunityRecommendationService>();
builder.Services.AddScoped<ISessionIngestionService, SessionIngestionService>();
builder.Services.AddScoped<IEventLifecycleOrchestrator, EventLifecycleOrchestrator>();
builder.Services.AddScoped<IMentorProfileRepository, MentorProfileRepository>();
builder.Services.AddScoped<MentorshipService>();

var app = builder.Build();

Expand Down Expand Up @@ -90,6 +94,7 @@ await executionStrategy.ExecuteAsync(async () =>
app.MapProfileEndpoints();
app.MapCommunityPassportEndpoints();
app.MapPlanningCycleEndpoints();
app.MapMentorshipEndpoints();

app.MapDefaultEndpoints();

Expand Down
Loading
Loading