add organizer ability to generate matching pairs

This commit is contained in:
2026-02-04 20:17:01 -07:00
parent 099d70e8a9
commit e38b752b27
6 changed files with 306 additions and 20 deletions

View File

@@ -259,6 +259,7 @@ interface Member {
memberId: number;
}
// there's a similar structure in OnboardMeetingSetupView.vue but without the member
interface DecryptedMember {
member: Member;
name: string;
@@ -488,7 +489,7 @@ export default class MembersList extends Vue {
informAboutAdmission() {
this.notify.info(
"This is to register people in the app and to admit them to the meeting. A (+) symbol means they are not yet admitted and you can register and admit them. A (-) symbol means you can remove them, but they will stay registered.",
"This is to register people in the app and to admit them to the meeting. A green (+) symbol means they are not yet admitted and you can register and admit them. A red (-) symbol means you can remove them, but they will stay registered.",
TIMEOUTS.VERY_LONG,
);
}

View File

@@ -55,6 +55,9 @@ export interface AxiosErrorResponse {
response?: {
data?: {
error?: {
// This is in responses from endorser-ch server
userMessage?: string;
// This is the old approach from endorser-ch server; remove when we've removed all "error: { message: ... }"
message?: string;
};
[key: string]: unknown;

View File

@@ -676,15 +676,16 @@ export async function setPlanInCache(
/**
* Extracts user-friendly message from server error
* @param {any} error - Error thrown from Endorser server call
* @param {AxiosErrorResponse} error - Error thrown from Endorser server call
* @returns {string|undefined} User-friendly message or undefined if none found
*/
export function serverMessageForUser(error: unknown): string | undefined {
if (error && typeof error === "object" && "response" in error) {
const err = error as AxiosErrorResponse;
return err.response?.data?.error?.message;
}
return undefined;
export function serverMessageForUser(
error: AxiosErrorResponse,
): string | undefined {
return (
error?.response?.data?.error?.userMessage ||
error?.response?.data?.error?.message
);
}
/**

View File

@@ -131,6 +131,7 @@ import {
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NotificationIface } from "@/constants/app";
import { AxiosErrorResponse } from "@/interfaces";
interface Meeting {
name: string;
@@ -209,11 +210,6 @@ export default class OnboardMeetingListView extends Vue {
* 3. If not attending: Fetch all available meetings (groupsOnboarding endpoint)
* 4. Handle loading states and error conditions
*
* API Endpoints Used:
* - GET /api/partner/groupOnboardMember - Check current attendance
* - GET /api/partner/groupOnboard/{id} - Get meeting details
* - GET /api/partner/groupsOnboarding - Get all available meetings
*
* State Management:
* - Sets isLoading flag during API calls
* - Updates attendingMeeting or meetings array
@@ -271,7 +267,8 @@ export default class OnboardMeetingListView extends Vue {
true,
);
this.notify.error(
serverMessageForUser(error) || "There was a problem fetching meetings.",
serverMessageForUser(error as unknown as AxiosErrorResponse) ||
"There was a problem fetching meetings.",
TIMEOUTS.LONG,
);
} finally {

View File

@@ -78,6 +78,7 @@ import {
import { generateSaveAndActivateIdentity } from "../libs/util";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { NotificationIface } from "../constants/app";
import { AxiosErrorResponse } from "@/interfaces";
@Component({
components: {
@@ -178,7 +179,7 @@ export default class OnboardMeetingMembersView extends Vue {
}
} catch (error) {
this.errorMessage =
serverMessageForUser(error) ||
serverMessageForUser(error as unknown as AxiosErrorResponse) ||
"There was an error checking for that meeting. Reload or go back and try again.";
this.$logAndConsole(
"Error checking meeting: " + errorStringForLog(error),

View File

@@ -64,7 +64,7 @@
<div v-if="currentMeeting.password" class="mt-4">
<p class="text-gray-600">
Share the password with the members. You can also send them the
"shortcut page for members" link below.
"Page for Members" link below.
</p>
</div>
<div v-else class="text-red-600">
@@ -314,6 +314,88 @@
class="mt-4"
@error="handleMembersError"
/>
<!-- Pairwise matches (organizer only: this page is organizer's meeting) -->
<div class="mt-6 pt-4 border-t border-gray-200">
<h3 class="font-semibold mb-2">Pairs</h3>
<p class="text-sm text-gray-600 mb-3">
Match members by profile similarity
</p>
<div class="flex flex-wrap gap-2 mb-4">
<button
type="button"
class="px-3 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isPostingMatch"
@click="postNewMatchesThenRefresh()"
>
<font-awesome
v-if="isPostingMatch"
icon="spinner"
class="fa-spin fa-fw"
/>
Make New Matches
</button>
<button
type="button"
class="px-3 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isPostingMatch || !matchPairs?.length"
@click="clearMatchesThenRefresh()"
>
<font-awesome
v-if="isPostingMatch"
icon="spinner"
class="fa-spin fa-fw"
/>
Erase & Start Over
</button>
</div>
<div v-if="isLoadingMatches" class="text-sm text-gray-500 py-2">
<font-awesome icon="spinner" class="fa-spin fa-fw" />
Loading matches…
</div>
<ul
v-else-if="matchPairs && matchPairs.length > 0"
class="list-none space-y-3 text-sm"
>
<li
v-for="pair in matchPairs"
:key="pair.pairNumber"
class="p-3 rounded border border-gray-200 bg-gray-50"
>
<span class="font-medium">Pair {{ pair.pairNumber }}</span>
<span class="text-gray-600">
(similarity {{ pair.similarity.toFixed(2) }})</span
>
<ul class="mt-2 ml-2 space-y-1">
<li
v-for="p in pair.participants"
:key="p.issuerDid"
class="text-gray-700"
>
{{
p.decryptedContentObject?.name
? p.decryptedContentObject.name
: "(No Name)"
}}
-
{{
p.description
? p.description.substring(0, 80) +
(p.description.length > 80 ? "…" : "")
: "(No Profile)"
}}
</li>
</ul>
</li>
</ul>
<p
v-else-if="matchPairs && matchPairs.length === 0"
class="text-sm text-gray-500 py-2"
>
No matches yet. Click “Get matches” to pair members by profile
similarity.
</p>
</div>
</div>
<div
@@ -355,7 +437,7 @@ import {
serverMessageForUser,
didInfo,
} from "../libs/endorserServer";
import { encryptMessage } from "../libs/crypto";
import { encryptMessage, decryptMessage } from "../libs/crypto";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { APP_SERVER } from "@/constants/app";
@@ -369,6 +451,7 @@ import {
} from "@/constants/notifications";
import { PlanData } from "../interfaces/records";
import { Contact } from "../db/tables/contacts";
import { AxiosErrorResponse } from "@/interfaces";
interface ServerMeeting {
groupId: number; // from the server
name: string; // to & from the server
@@ -386,6 +469,25 @@ interface MeetingSetupInputs {
projectLink: string;
}
/** Pair from GET/POST /api/partner/groupOnboardMatch */
interface MatchPairParticipant {
issuerDid: string;
content: string;
// there's a similar structure in MembersList.vue with extra Member info
decryptedContentObject: {
name: string;
did: string;
isRegistered: boolean;
};
description: string;
}
interface MatchPair {
pairNumber: number;
similarity: number;
participants: MatchPairParticipant[];
}
@Component({
components: {
QuickNav,
@@ -409,16 +511,23 @@ export default class OnboardMeetingView extends Vue {
currentMeeting: ServerMeeting | null = null;
newOrUpdatedMeetingInputs: MeetingSetupInputs | null = null;
activeDid = "";
allContacts: Contact[] = [];
allMyDids: string[] = [];
apiServer = "";
isDeleting = false;
isLoading = true;
isLoadingMatches = false;
isPostingMatch = false;
isRegistered = false;
showDeleteConfirm = false;
fullName = "";
allContacts: Contact[] = [];
allMyDids: string[] = [];
matchPairs: MatchPair[] | null = null;
/** Accumulated pair DIDs from every match run; sent as previousPairDids on future posts. */
previousMatchedPairs: [string, string][] = [];
selectedProjectData: PlanData | null = null;
showDeleteConfirm = false;
get minDateTime() {
const now = new Date();
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
@@ -446,6 +555,11 @@ export default class OnboardMeetingView extends Vue {
// Ensure selected project is loaded if projectLink exists
await this.ensureSelectedProjectLoaded();
// Load pairwise matches when organizer has a meeting
if (this.currentMeeting?.password) {
await this.fetchMatchPairs();
}
this.isLoading = false;
}
@@ -833,6 +947,175 @@ export default class OnboardMeetingView extends Vue {
return "";
}
/**
* Fetch current pairwise matches (GET /api/partner/groupOnboardMatch).
* Any member of the meeting can fetch; organizer is the one who can POST.
*/
async fetchMatchPairs(): Promise<void> {
if (!this.currentMeeting?.password) return;
this.isLoadingMatches = true;
try {
const headers = await getHeaders(this.activeDid);
const response = await this.axios.get(
this.apiServer + "/api/partner/groupOnboardMatch",
{ headers },
);
const pairs = response?.data?.data?.pairs ?? null;
let tempMatchPairs: MatchPair[] | null = null;
if (Array.isArray(pairs)) {
tempMatchPairs = [];
// walk through pairs and decrypt the content for each participant
for (const pair of pairs) {
for (const participant of pair.participants) {
try {
const decryptedContent = await decryptMessage(
participant.content,
this.currentMeeting?.password || "",
);
participant.decryptedContentObject = JSON.parse(decryptedContent);
} catch (error) {
this.$logAndConsole(
"Error decrypting participant content: " +
errorStringForLog(error),
true,
);
participant.decryptedContentObject = null;
}
}
tempMatchPairs.push(pair);
}
}
this.matchPairs = tempMatchPairs;
if (tempMatchPairs?.length) {
this.mergePairsIntoPrevious(tempMatchPairs);
}
} catch (error) {
this.$logAndConsole(
"Error fetching match pairs: " + errorStringForLog(error),
true,
);
this.matchPairs = null;
this.notify.error(
serverMessageForUser(error as unknown as AxiosErrorResponse) ||
"Failed to load matches.",
TIMEOUTS.LONG,
);
} finally {
this.isLoadingMatches = false;
}
}
/**
* Normalize a pair of DIDs to a sorted tuple for deduplication.
*/
private normalizedPair(did1: string, did2: string): [string, string] {
return did1 <= did2 ? [did1, did2] : [did2, did1];
}
/**
* Append pairs from a match result into previousMatchedPairs (no duplicates).
*/
private mergePairsIntoPrevious(pairs: MatchPair[]): void {
for (const pair of pairs) {
if (pair.participants?.length !== 2) continue;
const [a, b] = pair.participants.map((p) => p.issuerDid);
const norm = this.normalizedPair(a, b);
const exists = this.previousMatchedPairs.some(
([x, y]) => x === norm[0] && y === norm[1],
);
if (!exists) {
this.previousMatchedPairs.push(norm);
}
}
}
/**
* POST to groupOnboardMatch with optional body (excludedDids, excludedPairDids, previousPairDids).
* Organizer only; uses meeting for current user.
*/
async postMatch(body?: {
excludedDids?: string[];
excludedPairDids?: [string, string][];
previousPairDids?: [string, string][];
}): Promise<MatchPair[] | null> {
try {
const headers = await getHeaders(this.activeDid);
const response = await this.axios.post(
this.apiServer + "/api/partner/groupOnboardMatch",
body ?? {},
{ headers },
);
const pairs = response?.data?.data?.pairs ?? null;
return Array.isArray(pairs) ? pairs : null;
} catch (error) {
this.$logAndConsole(
"Error posting group onboard match: " + errorStringForLog(error),
true,
);
const errorMessage = serverMessageForUser(
error as unknown as AxiosErrorResponse,
);
this.notify.error(
errorMessage || "Failed to run matching.",
TIMEOUTS.LONG,
);
return null;
}
}
async postNewMatchesThenRefresh(): Promise<void> {
this.isPostingMatch = true;
try {
const previousPairDids = this.previousMatchedPairs.length
? this.previousMatchedPairs
: undefined;
const pairs = await this.postMatch(
previousPairDids ? { previousPairDids } : undefined,
);
if (Array.isArray(pairs) && pairs.length > 0) {
const tempMatchPairs: MatchPair[] = [];
for (const pair of pairs) {
for (const participant of pair.participants) {
const decryptedContent = await decryptMessage(
participant.content,
this.currentMeeting?.password || "",
);
participant.decryptedContentObject = JSON.parse(decryptedContent);
}
tempMatchPairs.push(pair);
}
this.matchPairs = tempMatchPairs;
this.mergePairsIntoPrevious(tempMatchPairs);
this.notify.success("New matches generated.", TIMEOUTS.STANDARD);
}
} finally {
this.isPostingMatch = false;
}
}
async clearMatchesThenRefresh(): Promise<void> {
try {
const headers = await getHeaders(this.activeDid);
await this.axios.delete(
this.apiServer + "/api/partner/groupOnboardMatch",
{ headers },
);
this.matchPairs = null;
this.previousMatchedPairs = [];
this.notify.success("Matches cleared.", TIMEOUTS.STANDARD);
} catch (error) {
this.$logAndConsole(
"Error clearing matches: " + errorStringForLog(error),
true,
);
this.notify.error(
serverMessageForUser(error as unknown as AxiosErrorResponse) ||
"Failed to clear matches.",
TIMEOUTS.LONG,
);
}
}
handleMembersError(message: string) {
this.notify.error(message, TIMEOUTS.LONG);
}