add organizer ability to generate matching pairs
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user