31 changed files with 1404 additions and 990 deletions
@ -0,0 +1,506 @@ |
|||||
|
<template> |
||||
|
<div v-if="visible" class="dialog-overlay"> |
||||
|
<div class="dialog"> |
||||
|
<div class="text-slate-900 text-center"> |
||||
|
<h3 class="text-lg font-semibold leading-[1.25] mb-2"> |
||||
|
{{ title }} |
||||
|
</h3> |
||||
|
<p class="text-sm mb-4"> |
||||
|
{{ description }} |
||||
|
</p> |
||||
|
|
||||
|
<!-- Member Selection Table --> |
||||
|
<div class="mb-4"> |
||||
|
<table |
||||
|
class="w-full border-collapse border border-slate-300 text-sm text-start" |
||||
|
> |
||||
|
<!-- Select All Header --> |
||||
|
<thead v-if="membersData && membersData.length > 0"> |
||||
|
<tr class="bg-slate-100 font-medium"> |
||||
|
<th class="border border-slate-300 px-3 py-2"> |
||||
|
<label class="flex items-center gap-2"> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
:checked="isAllSelected" |
||||
|
:indeterminate="isIndeterminate" |
||||
|
@change="toggleSelectAll" |
||||
|
/> |
||||
|
Select All |
||||
|
</label> |
||||
|
</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
<!-- Empty State --> |
||||
|
<tr v-if="!membersData || membersData.length === 0"> |
||||
|
<td |
||||
|
class="border border-slate-300 px-3 py-2 text-center italic text-gray-500" |
||||
|
> |
||||
|
{{ emptyStateText }} |
||||
|
</td> |
||||
|
</tr> |
||||
|
<!-- Member Rows --> |
||||
|
<tr |
||||
|
v-for="member in membersData || []" |
||||
|
:key="member.member.memberId" |
||||
|
> |
||||
|
<td class="border border-slate-300 px-3 py-2"> |
||||
|
<div class="flex items-center justify-between gap-2"> |
||||
|
<label class="flex items-center gap-2"> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
:checked="isMemberSelected(member.did)" |
||||
|
@change="toggleMemberSelection(member.did)" |
||||
|
/> |
||||
|
<div class=""> |
||||
|
<div class="text-sm font-semibold"> |
||||
|
{{ member.name || SOMEONE_UNNAMED }} |
||||
|
</div> |
||||
|
<div |
||||
|
class="flex items-center gap-0.5 text-xs text-slate-500" |
||||
|
> |
||||
|
<span class="font-semibold sm:hidden">DID:</span> |
||||
|
<span |
||||
|
class="w-[35vw] sm:w-auto truncate text-left" |
||||
|
style="direction: rtl" |
||||
|
>{{ member.did }}</span |
||||
|
> |
||||
|
</div> |
||||
|
</div> |
||||
|
</label> |
||||
|
|
||||
|
<!-- Contact indicator - only show if they are already a contact --> |
||||
|
<font-awesome |
||||
|
v-if="member.isContact" |
||||
|
icon="user-circle" |
||||
|
class="fa-fw ms-auto text-slate-400 cursor-pointer hover:text-slate-600" |
||||
|
@click="showContactInfo" |
||||
|
/> |
||||
|
</div> |
||||
|
</td> |
||||
|
</tr> |
||||
|
</tbody> |
||||
|
<!-- Select All Footer --> |
||||
|
<tfoot v-if="membersData && membersData.length > 0"> |
||||
|
<tr class="bg-slate-100 font-medium"> |
||||
|
<th class="border border-slate-300 px-3 py-2"> |
||||
|
<label class="flex items-center gap-2"> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
:checked="isAllSelected" |
||||
|
:indeterminate="isIndeterminate" |
||||
|
@change="toggleSelectAll" |
||||
|
/> |
||||
|
Select All |
||||
|
</label> |
||||
|
</th> |
||||
|
</tr> |
||||
|
</tfoot> |
||||
|
</table> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Action Buttons --> |
||||
|
<div class="space-y-2"> |
||||
|
<!-- Main Action Button --> |
||||
|
<button |
||||
|
v-if="membersData && membersData.length > 0" |
||||
|
:disabled="!hasSelectedMembers" |
||||
|
:class="[ |
||||
|
'block w-full text-center text-md font-bold uppercase px-2 py-2 rounded-md', |
||||
|
hasSelectedMembers |
||||
|
? 'bg-blue-600 text-white cursor-pointer' |
||||
|
: 'bg-slate-400 text-slate-200 cursor-not-allowed', |
||||
|
]" |
||||
|
@click="handleMainAction" |
||||
|
> |
||||
|
{{ buttonText }} |
||||
|
</button> |
||||
|
<!-- Cancel Button --> |
||||
|
<button |
||||
|
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md" |
||||
|
@click="cancel" |
||||
|
> |
||||
|
Maybe Later |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Vue, Component, Prop } from "vue-facing-decorator"; |
||||
|
|
||||
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; |
||||
|
import { SOMEONE_UNNAMED } from "@/constants/entities"; |
||||
|
import { MemberData } from "@/interfaces"; |
||||
|
import { setVisibilityUtil, getHeaders, register } from "@/libs/endorserServer"; |
||||
|
import { createNotifyHelpers } from "@/utils/notify"; |
||||
|
import { Contact } from "@/db/tables/contacts"; |
||||
|
|
||||
|
@Component({ |
||||
|
mixins: [PlatformServiceMixin], |
||||
|
emits: ["close"], |
||||
|
}) |
||||
|
export default class BulkMembersDialog extends Vue { |
||||
|
@Prop({ default: "" }) activeDid!: string; |
||||
|
@Prop({ default: "" }) apiServer!: string; |
||||
|
@Prop({ required: true }) dialogType!: "admit" | "visibility"; |
||||
|
@Prop({ required: true }) isOrganizer!: boolean; |
||||
|
|
||||
|
// Vue notification system |
||||
|
$notify!: ( |
||||
|
notification: { group: string; type: string; title: string; text: string }, |
||||
|
timeout?: number, |
||||
|
) => void; |
||||
|
|
||||
|
// Notification system |
||||
|
notify!: ReturnType<typeof createNotifyHelpers>; |
||||
|
|
||||
|
// Component state |
||||
|
membersData: MemberData[] = []; |
||||
|
selectedMembers: string[] = []; |
||||
|
visible = false; |
||||
|
|
||||
|
// Constants |
||||
|
// In Vue templates, imported constants need to be explicitly made available to the template |
||||
|
readonly SOMEONE_UNNAMED = SOMEONE_UNNAMED; |
||||
|
|
||||
|
get hasSelectedMembers() { |
||||
|
return this.selectedMembers.length > 0; |
||||
|
} |
||||
|
|
||||
|
get isAllSelected() { |
||||
|
if (!this.membersData || this.membersData.length === 0) return false; |
||||
|
return this.membersData.every((member) => |
||||
|
this.selectedMembers.includes(member.did), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
get isIndeterminate() { |
||||
|
if (!this.membersData || this.membersData.length === 0) return false; |
||||
|
const selectedCount = this.membersData.filter((member) => |
||||
|
this.selectedMembers.includes(member.did), |
||||
|
).length; |
||||
|
return selectedCount > 0 && selectedCount < this.membersData.length; |
||||
|
} |
||||
|
|
||||
|
get title() { |
||||
|
return this.isOrganizer |
||||
|
? "Admit Pending Members" |
||||
|
: "Add Members to Contacts"; |
||||
|
} |
||||
|
|
||||
|
get description() { |
||||
|
return this.isOrganizer |
||||
|
? "Would you like to admit these members to the meeting and add them to your contacts?" |
||||
|
: "Would you like to add these members to your contacts?"; |
||||
|
} |
||||
|
|
||||
|
get buttonText() { |
||||
|
return this.isOrganizer ? "Admit + Add to Contacts" : "Add to Contacts"; |
||||
|
} |
||||
|
|
||||
|
get emptyStateText() { |
||||
|
return this.isOrganizer |
||||
|
? "No pending members to admit" |
||||
|
: "No members are not in your contacts"; |
||||
|
} |
||||
|
|
||||
|
created() { |
||||
|
this.notify = createNotifyHelpers(this.$notify); |
||||
|
} |
||||
|
|
||||
|
open(members: MemberData[]) { |
||||
|
this.visible = true; |
||||
|
this.membersData = members; |
||||
|
// Select all by default |
||||
|
this.selectedMembers = this.membersData.map((member) => member.did); |
||||
|
} |
||||
|
|
||||
|
close(notSelectedMemberDids: string[]) { |
||||
|
this.visible = false; |
||||
|
this.$emit("close", { notSelectedMemberDids: notSelectedMemberDids }); |
||||
|
} |
||||
|
|
||||
|
cancel() { |
||||
|
this.close(this.membersData.map((member) => member.did)); |
||||
|
} |
||||
|
|
||||
|
toggleSelectAll() { |
||||
|
if (!this.membersData || this.membersData.length === 0) return; |
||||
|
|
||||
|
if (this.isAllSelected) { |
||||
|
// Deselect all |
||||
|
this.selectedMembers = []; |
||||
|
} else { |
||||
|
// Select all |
||||
|
this.selectedMembers = this.membersData.map((member) => member.did); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
toggleMemberSelection(memberDid: string) { |
||||
|
const index = this.selectedMembers.indexOf(memberDid); |
||||
|
if (index > -1) { |
||||
|
this.selectedMembers.splice(index, 1); |
||||
|
} else { |
||||
|
this.selectedMembers.push(memberDid); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
isMemberSelected(memberDid: string) { |
||||
|
return this.selectedMembers.includes(memberDid); |
||||
|
} |
||||
|
|
||||
|
async handleMainAction() { |
||||
|
if (this.dialogType === "admit") { |
||||
|
await this.organizerAdmitAndAddWithVisibility(); |
||||
|
} else { |
||||
|
await this.memberAddContactWithVisibility(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async organizerAdmitAndAddWithVisibility() { |
||||
|
try { |
||||
|
const selectedMembers: MemberData[] = this.membersData.filter((member) => |
||||
|
this.selectedMembers.includes(member.did), |
||||
|
); |
||||
|
const notSelectedMembers: MemberData[] = this.membersData.filter( |
||||
|
(member) => !this.selectedMembers.includes(member.did), |
||||
|
); |
||||
|
|
||||
|
let admittedCount = 0; |
||||
|
let contactAddedCount = 0; |
||||
|
let errors = 0; |
||||
|
|
||||
|
for (const member of selectedMembers) { |
||||
|
try { |
||||
|
// First, admit the member |
||||
|
await this.admitMember(member); |
||||
|
|
||||
|
// Register them |
||||
|
await this.registerMember(member); |
||||
|
admittedCount++; |
||||
|
|
||||
|
// If they're not a contact yet, add them as a contact |
||||
|
if (!member.isContact) { |
||||
|
await this.addAsContact(member, true); |
||||
|
contactAddedCount++; |
||||
|
} |
||||
|
|
||||
|
// Set their seesMe to true |
||||
|
await this.updateContactVisibility(member.did, true); |
||||
|
} catch (error) { |
||||
|
// eslint-disable-next-line no-console |
||||
|
console.error(`Error processing member ${member.did}:`, error); |
||||
|
// Continue with other members even if one fails |
||||
|
errors++; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Show success notification |
||||
|
if (admittedCount > 0) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Members Admitted Successfully", |
||||
|
text: `${admittedCount} member${admittedCount === 1 ? "" : "s"} admitted and registered${contactAddedCount === 0 ? "" : admittedCount === contactAddedCount ? " and" : `, ${contactAddedCount}`}${contactAddedCount === 0 ? "" : ` added as contact${contactAddedCount === 1 ? "" : "s"}`}.`, |
||||
|
}, |
||||
|
10000, |
||||
|
); |
||||
|
} |
||||
|
if (errors > 0) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "Failed to fully admit some members. Work with them individually below.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
this.close(notSelectedMembers.map((member) => member.did)); |
||||
|
} catch (error) { |
||||
|
// eslint-disable-next-line no-console |
||||
|
console.error("Error admitting members:", error); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "Some errors occurred. Work with members individually below.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async memberAddContactWithVisibility() { |
||||
|
try { |
||||
|
const selectedMembers: MemberData[] = this.membersData.filter((member) => |
||||
|
this.selectedMembers.includes(member.did), |
||||
|
); |
||||
|
const notSelectedMembers: MemberData[] = this.membersData.filter( |
||||
|
(member) => !this.selectedMembers.includes(member.did), |
||||
|
); |
||||
|
|
||||
|
let contactsAddedCount = 0; |
||||
|
|
||||
|
for (const member of selectedMembers) { |
||||
|
try { |
||||
|
// If they're not a contact yet, add them as a contact first |
||||
|
if (!member.isContact) { |
||||
|
await this.addAsContact(member, undefined); |
||||
|
contactsAddedCount++; |
||||
|
} |
||||
|
|
||||
|
// Set their seesMe to true |
||||
|
await this.updateContactVisibility(member.did, true); |
||||
|
} catch (error) { |
||||
|
// eslint-disable-next-line no-console |
||||
|
console.error(`Error processing member ${member.did}:`, error); |
||||
|
// Continue with other members even if one fails |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Show success notification |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Contacts Added Successfully", |
||||
|
text: `${contactsAddedCount} member${contactsAddedCount === 1 ? "" : "s"} added as contact${contactsAddedCount === 1 ? "" : "s"}.`, |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
|
||||
|
this.close(notSelectedMembers.map((member) => member.did)); |
||||
|
} catch (error) { |
||||
|
// eslint-disable-next-line no-console |
||||
|
console.error("Error adding contacts:", error); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "Some errors occurred. Work with members individually below.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async admitMember(member: { |
||||
|
did: string; |
||||
|
name: string; |
||||
|
member: { memberId: string }; |
||||
|
}) { |
||||
|
try { |
||||
|
const headers = await getHeaders(this.activeDid); |
||||
|
await this.axios.put( |
||||
|
`${this.apiServer}/api/partner/groupOnboardMember/${member.member.memberId}`, |
||||
|
{ admitted: true }, |
||||
|
{ headers }, |
||||
|
); |
||||
|
} catch (err) { |
||||
|
// eslint-disable-next-line no-console |
||||
|
console.error("Error admitting member:", err); |
||||
|
throw err; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async registerMember(member: MemberData) { |
||||
|
try { |
||||
|
const contact: Contact = { did: member.did }; |
||||
|
const result = await register( |
||||
|
this.activeDid, |
||||
|
this.apiServer, |
||||
|
this.axios, |
||||
|
contact, |
||||
|
); |
||||
|
if (result.success) { |
||||
|
if (result.embeddedRecordError) { |
||||
|
throw new Error(result.embeddedRecordError); |
||||
|
} |
||||
|
await this.$updateContact(member.did, { registered: true }); |
||||
|
} else { |
||||
|
throw result; |
||||
|
} |
||||
|
} catch (err) { |
||||
|
// eslint-disable-next-line no-console |
||||
|
console.error("Error registering member:", err); |
||||
|
throw err; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async addAsContact( |
||||
|
member: { did: string; name: string }, |
||||
|
isRegistered?: boolean, |
||||
|
) { |
||||
|
try { |
||||
|
const newContact: Contact = { |
||||
|
did: member.did, |
||||
|
name: member.name, |
||||
|
registered: isRegistered, |
||||
|
}; |
||||
|
|
||||
|
await this.$insertContact(newContact); |
||||
|
} catch (err) { |
||||
|
// eslint-disable-next-line no-console |
||||
|
console.error("Error adding contact:", err); |
||||
|
if (err instanceof Error && err.message?.indexOf("already exists") > -1) { |
||||
|
// Contact already exists, continue |
||||
|
} else { |
||||
|
throw err; // Re-throw if it's not a duplicate error |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async updateContactVisibility(did: string, seesMe: boolean) { |
||||
|
try { |
||||
|
// Get the contact object |
||||
|
const contact = await this.$getContact(did); |
||||
|
if (!contact) { |
||||
|
throw new Error(`Contact not found for DID: ${did}`); |
||||
|
} |
||||
|
|
||||
|
// Use the proper API to set visibility on the server |
||||
|
const result = await setVisibilityUtil( |
||||
|
this.activeDid, |
||||
|
this.apiServer, |
||||
|
this.axios, |
||||
|
contact, |
||||
|
seesMe, |
||||
|
); |
||||
|
|
||||
|
if (!result.success) { |
||||
|
throw new Error(result.error || "Failed to set visibility"); |
||||
|
} |
||||
|
} catch (err) { |
||||
|
// eslint-disable-next-line no-console |
||||
|
console.error("Error updating contact visibility:", err); |
||||
|
throw err; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
showContactInfo() { |
||||
|
const message = |
||||
|
this.dialogType === "admit" |
||||
|
? "This user is already your contact, but they are not yet admitted to the meeting." |
||||
|
: "This user is already your contact, but your activities are not visible to them yet."; |
||||
|
|
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Contact Info", |
||||
|
text: message, |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
@ -1,333 +0,0 @@ |
|||||
<template> |
|
||||
<div v-if="visible" class="dialog-overlay"> |
|
||||
<div class="dialog"> |
|
||||
<div class="text-slate-900 text-center"> |
|
||||
<h3 class="text-lg font-semibold leading-[1.25] mb-2"> |
|
||||
Set Visibility to Meeting Members |
|
||||
</h3> |
|
||||
<p class="text-sm mb-4"> |
|
||||
Would you like to <b>make your activities visible</b> to the following |
|
||||
members? (This will also add them as contacts if they aren't already.) |
|
||||
</p> |
|
||||
|
|
||||
<!-- Custom table area - you can customize this --> |
|
||||
<div v-if="shouldInitializeSelection" class="mb-4"> |
|
||||
<table |
|
||||
class="w-full border-collapse border border-slate-300 text-sm text-start" |
|
||||
> |
|
||||
<thead v-if="membersData && membersData.length > 0"> |
|
||||
<tr class="bg-slate-100 font-medium"> |
|
||||
<th class="border border-slate-300 px-3 py-2"> |
|
||||
<label class="flex items-center gap-2"> |
|
||||
<input |
|
||||
type="checkbox" |
|
||||
:checked="isAllSelected" |
|
||||
:indeterminate="isIndeterminate" |
|
||||
@change="toggleSelectAll" |
|
||||
/> |
|
||||
Select All |
|
||||
</label> |
|
||||
</th> |
|
||||
</tr> |
|
||||
</thead> |
|
||||
<tbody> |
|
||||
<!-- Dynamic data from MembersList --> |
|
||||
<tr v-if="!membersData || membersData.length === 0"> |
|
||||
<td |
|
||||
class="border border-slate-300 px-3 py-2 text-center italic text-gray-500" |
|
||||
> |
|
||||
No members need visibility settings |
|
||||
</td> |
|
||||
</tr> |
|
||||
<tr |
|
||||
v-for="member in membersData || []" |
|
||||
:key="member.member.memberId" |
|
||||
> |
|
||||
<td class="border border-slate-300 px-3 py-2"> |
|
||||
<div class="flex items-center justify-between gap-2"> |
|
||||
<label class="flex items-center gap-2"> |
|
||||
<input |
|
||||
type="checkbox" |
|
||||
:checked="isMemberSelected(member.did)" |
|
||||
@change="toggleMemberSelection(member.did)" |
|
||||
/> |
|
||||
{{ member.name || SOMEONE_UNNAMED }} |
|
||||
</label> |
|
||||
|
|
||||
<!-- Friend indicator - only show if they are already a contact --> |
|
||||
<font-awesome |
|
||||
v-if="member.isContact" |
|
||||
icon="user-circle" |
|
||||
class="fa-fw ms-auto text-slate-400 cursor-pointer hover:text-slate-600" |
|
||||
@click="showContactInfo" |
|
||||
/> |
|
||||
</div> |
|
||||
</td> |
|
||||
</tr> |
|
||||
</tbody> |
|
||||
</table> |
|
||||
</div> |
|
||||
|
|
||||
<div class="space-y-2"> |
|
||||
<button |
|
||||
v-if="membersData && membersData.length > 0" |
|
||||
:disabled="!hasSelectedMembers" |
|
||||
:class="[ |
|
||||
'block w-full text-center text-md font-bold uppercase px-2 py-2 rounded-md', |
|
||||
hasSelectedMembers |
|
||||
? 'bg-blue-600 text-white cursor-pointer' |
|
||||
: 'bg-slate-400 text-slate-200 cursor-not-allowed', |
|
||||
]" |
|
||||
@click="setVisibilityForSelectedMembers" |
|
||||
> |
|
||||
Set Visibility |
|
||||
</button> |
|
||||
<button |
|
||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md" |
|
||||
@click="cancel" |
|
||||
> |
|
||||
{{ |
|
||||
membersData && membersData.length > 0 ? "Maybe Later" : "Cancel" |
|
||||
}} |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</template> |
|
||||
|
|
||||
<script lang="ts"> |
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator"; |
|
||||
|
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; |
|
||||
import { SOMEONE_UNNAMED } from "@/constants/entities"; |
|
||||
import { setVisibilityUtil } from "@/libs/endorserServer"; |
|
||||
import { createNotifyHelpers } from "@/utils/notify"; |
|
||||
|
|
||||
interface MemberData { |
|
||||
did: string; |
|
||||
name: string; |
|
||||
isContact: boolean; |
|
||||
member: { |
|
||||
memberId: string; |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
@Component({ |
|
||||
mixins: [PlatformServiceMixin], |
|
||||
}) |
|
||||
export default class SetBulkVisibilityDialog extends Vue { |
|
||||
@Prop({ default: false }) visible!: boolean; |
|
||||
@Prop({ default: () => [] }) membersData!: MemberData[]; |
|
||||
@Prop({ default: "" }) activeDid!: string; |
|
||||
@Prop({ default: "" }) apiServer!: string; |
|
||||
|
|
||||
// Vue notification system |
|
||||
$notify!: ( |
|
||||
notification: { group: string; type: string; title: string; text: string }, |
|
||||
timeout?: number, |
|
||||
) => void; |
|
||||
|
|
||||
// Notification system |
|
||||
notify!: ReturnType<typeof createNotifyHelpers>; |
|
||||
|
|
||||
// Component state |
|
||||
selectedMembers: string[] = []; |
|
||||
selectionInitialized = false; |
|
||||
|
|
||||
// Constants |
|
||||
// In Vue templates, imported constants need to be explicitly made available to the template |
|
||||
readonly SOMEONE_UNNAMED = SOMEONE_UNNAMED; |
|
||||
|
|
||||
get hasSelectedMembers() { |
|
||||
return this.selectedMembers.length > 0; |
|
||||
} |
|
||||
|
|
||||
get isAllSelected() { |
|
||||
if (!this.membersData || this.membersData.length === 0) return false; |
|
||||
return this.membersData.every((member) => |
|
||||
this.selectedMembers.includes(member.did), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
get isIndeterminate() { |
|
||||
if (!this.membersData || this.membersData.length === 0) return false; |
|
||||
const selectedCount = this.membersData.filter((member) => |
|
||||
this.selectedMembers.includes(member.did), |
|
||||
).length; |
|
||||
return selectedCount > 0 && selectedCount < this.membersData.length; |
|
||||
} |
|
||||
|
|
||||
get shouldInitializeSelection() { |
|
||||
// This method will initialize selection when the dialog opens |
|
||||
if (!this.selectionInitialized) { |
|
||||
this.initializeSelection(); |
|
||||
this.selectionInitialized = true; |
|
||||
} |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
created() { |
|
||||
this.notify = createNotifyHelpers(this.$notify); |
|
||||
} |
|
||||
|
|
||||
initializeSelection() { |
|
||||
// Reset selection when dialog opens |
|
||||
this.selectedMembers = []; |
|
||||
// Select all by default |
|
||||
this.selectedMembers = this.membersData.map((member) => member.did); |
|
||||
} |
|
||||
|
|
||||
resetSelection() { |
|
||||
this.selectedMembers = []; |
|
||||
this.selectionInitialized = false; |
|
||||
} |
|
||||
|
|
||||
toggleSelectAll() { |
|
||||
if (!this.membersData || this.membersData.length === 0) return; |
|
||||
|
|
||||
if (this.isAllSelected) { |
|
||||
// Deselect all |
|
||||
this.selectedMembers = []; |
|
||||
} else { |
|
||||
// Select all |
|
||||
this.selectedMembers = this.membersData.map((member) => member.did); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
toggleMemberSelection(memberDid: string) { |
|
||||
const index = this.selectedMembers.indexOf(memberDid); |
|
||||
if (index > -1) { |
|
||||
this.selectedMembers.splice(index, 1); |
|
||||
} else { |
|
||||
this.selectedMembers.push(memberDid); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
isMemberSelected(memberDid: string) { |
|
||||
return this.selectedMembers.includes(memberDid); |
|
||||
} |
|
||||
|
|
||||
async setVisibilityForSelectedMembers() { |
|
||||
try { |
|
||||
const selectedMembers = this.membersData.filter((member) => |
|
||||
this.selectedMembers.includes(member.did), |
|
||||
); |
|
||||
|
|
||||
let successCount = 0; |
|
||||
|
|
||||
for (const member of selectedMembers) { |
|
||||
try { |
|
||||
// If they're not a contact yet, add them as a contact first |
|
||||
if (!member.isContact) { |
|
||||
await this.addAsContact(member); |
|
||||
} |
|
||||
|
|
||||
// Set their seesMe to true |
|
||||
await this.updateContactVisibility(member.did, true); |
|
||||
|
|
||||
successCount++; |
|
||||
} catch (error) { |
|
||||
// eslint-disable-next-line no-console |
|
||||
console.error(`Error processing member ${member.did}:`, error); |
|
||||
// Continue with other members even if one fails |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Show success notification |
|
||||
this.$notify( |
|
||||
{ |
|
||||
group: "alert", |
|
||||
type: "success", |
|
||||
title: "Visibility Set Successfully", |
|
||||
text: `Visibility set for ${successCount} member${successCount === 1 ? "" : "s"}.`, |
|
||||
}, |
|
||||
5000, |
|
||||
); |
|
||||
|
|
||||
// Emit success event |
|
||||
this.$emit("success", successCount); |
|
||||
this.close(); |
|
||||
} catch (error) { |
|
||||
// eslint-disable-next-line no-console |
|
||||
console.error("Error setting visibility:", error); |
|
||||
this.$notify( |
|
||||
{ |
|
||||
group: "alert", |
|
||||
type: "danger", |
|
||||
title: "Error", |
|
||||
text: "Failed to set visibility for some members. Please try again.", |
|
||||
}, |
|
||||
5000, |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async addAsContact(member: { did: string; name: string }) { |
|
||||
try { |
|
||||
const newContact = { |
|
||||
did: member.did, |
|
||||
name: member.name, |
|
||||
}; |
|
||||
|
|
||||
await this.$insertContact(newContact); |
|
||||
} catch (err) { |
|
||||
// eslint-disable-next-line no-console |
|
||||
console.error("Error adding contact:", err); |
|
||||
if (err instanceof Error && err.message?.indexOf("already exists") > -1) { |
|
||||
// Contact already exists, continue |
|
||||
} else { |
|
||||
throw err; // Re-throw if it's not a duplicate error |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async updateContactVisibility(did: string, seesMe: boolean) { |
|
||||
try { |
|
||||
// Get the contact object |
|
||||
const contact = await this.$getContact(did); |
|
||||
if (!contact) { |
|
||||
throw new Error(`Contact not found for DID: ${did}`); |
|
||||
} |
|
||||
|
|
||||
// Use the proper API to set visibility on the server |
|
||||
const result = await setVisibilityUtil( |
|
||||
this.activeDid, |
|
||||
this.apiServer, |
|
||||
this.axios, |
|
||||
contact, |
|
||||
seesMe, |
|
||||
); |
|
||||
|
|
||||
if (!result.success) { |
|
||||
throw new Error(result.error || "Failed to set visibility"); |
|
||||
} |
|
||||
} catch (err) { |
|
||||
// eslint-disable-next-line no-console |
|
||||
console.error("Error updating contact visibility:", err); |
|
||||
throw err; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
showContactInfo() { |
|
||||
this.$notify( |
|
||||
{ |
|
||||
group: "alert", |
|
||||
type: "info", |
|
||||
title: "Contact Info", |
|
||||
text: "This user is already your contact, but your activities are not visible to them yet.", |
|
||||
}, |
|
||||
5000, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
close() { |
|
||||
this.resetSelection(); |
|
||||
this.$emit("close"); |
|
||||
} |
|
||||
|
|
||||
cancel() { |
|
||||
this.close(); |
|
||||
} |
|
||||
} |
|
||||
</script> |
|
||||
@ -0,0 +1,297 @@ |
|||||
|
/** |
||||
|
* @fileoverview Base Database Service for Platform Services |
||||
|
* @author Matthew Raymer |
||||
|
* |
||||
|
* This abstract base class provides common database operations that are |
||||
|
* identical across all platform implementations. It eliminates code |
||||
|
* duplication and ensures consistency in database operations. |
||||
|
* |
||||
|
* Key Features: |
||||
|
* - Common database utility methods |
||||
|
* - Consistent settings management |
||||
|
* - Active identity management |
||||
|
* - Abstract methods for platform-specific database operations |
||||
|
* |
||||
|
* Architecture: |
||||
|
* - Abstract base class with common implementations |
||||
|
* - Platform services extend this class |
||||
|
* - Platform-specific database operations remain abstract |
||||
|
* |
||||
|
* @since 1.1.1-beta |
||||
|
*/ |
||||
|
|
||||
|
import { logger } from "../../utils/logger"; |
||||
|
import { QueryExecResult } from "@/interfaces/database"; |
||||
|
|
||||
|
/** |
||||
|
* Abstract base class for platform-specific database services. |
||||
|
* |
||||
|
* This class provides common database operations that are identical |
||||
|
* across all platform implementations (Web, Capacitor, Electron). |
||||
|
* Platform-specific services extend this class and implement the |
||||
|
* abstract database operation methods. |
||||
|
* |
||||
|
* Common Operations: |
||||
|
* - Settings management (update, retrieve, insert) |
||||
|
* - Active identity management |
||||
|
* - Database utility methods |
||||
|
* |
||||
|
* @abstract |
||||
|
* @example |
||||
|
* ```typescript
|
||||
|
* export class WebPlatformService extends BaseDatabaseService { |
||||
|
* async dbQuery(sql: string, params?: unknown[]): Promise<QueryExecResult> { |
||||
|
* // Web-specific implementation
|
||||
|
* } |
||||
|
* } |
||||
|
* ``` |
||||
|
*/ |
||||
|
export abstract class BaseDatabaseService { |
||||
|
/** |
||||
|
* Generate an INSERT statement for a model object. |
||||
|
* |
||||
|
* Creates a parameterized INSERT statement with placeholders for |
||||
|
* all properties in the model object. This ensures safe SQL |
||||
|
* execution and prevents SQL injection. |
||||
|
* |
||||
|
* @param model - Object containing the data to insert |
||||
|
* @param tableName - Name of the target table |
||||
|
* @returns Object containing the SQL statement and parameters |
||||
|
* |
||||
|
* @example |
||||
|
* ```typescript
|
||||
|
* const { sql, params } = this.generateInsertStatement( |
||||
|
* { name: 'John', age: 30 }, |
||||
|
* 'users' |
||||
|
* ); |
||||
|
* // sql: "INSERT INTO users (name, age) VALUES (?, ?)"
|
||||
|
* // params: ['John', 30]
|
||||
|
* ``` |
||||
|
*/ |
||||
|
generateInsertStatement( |
||||
|
model: Record<string, unknown>, |
||||
|
tableName: string, |
||||
|
): { sql: string; params: unknown[] } { |
||||
|
const keys = Object.keys(model); |
||||
|
const placeholders = keys.map(() => "?").join(", "); |
||||
|
const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`; |
||||
|
const params = keys.map((key) => model[key]); |
||||
|
return { sql, params }; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Update default settings for the currently active account. |
||||
|
* |
||||
|
* Retrieves the active DID from the active_identity table and updates |
||||
|
* the corresponding settings record. This ensures settings are always |
||||
|
* updated for the correct account. |
||||
|
* |
||||
|
* @param settings - Object containing the settings to update |
||||
|
* @returns Promise that resolves when settings are updated |
||||
|
* |
||||
|
* @throws {Error} If no active DID is found or database operation fails |
||||
|
* |
||||
|
* @example |
||||
|
* ```typescript
|
||||
|
* await this.updateDefaultSettings({ |
||||
|
* theme: 'dark', |
||||
|
* notifications: true |
||||
|
* }); |
||||
|
* ``` |
||||
|
*/ |
||||
|
async updateDefaultSettings( |
||||
|
settings: Record<string, unknown>, |
||||
|
): Promise<void> { |
||||
|
// Get current active DID and update that identity's settings
|
||||
|
const activeIdentity = await this.getActiveIdentity(); |
||||
|
const activeDid = activeIdentity.activeDid; |
||||
|
|
||||
|
if (!activeDid) { |
||||
|
logger.warn( |
||||
|
"[BaseDatabaseService] No active DID found, cannot update default settings", |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const keys = Object.keys(settings); |
||||
|
const setClause = keys.map((key) => `${key} = ?`).join(", "); |
||||
|
const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`; |
||||
|
const params = [...keys.map((key) => settings[key]), activeDid]; |
||||
|
await this.dbExec(sql, params); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Update the active DID in the active_identity table. |
||||
|
* |
||||
|
* Sets the active DID and updates the lastUpdated timestamp. |
||||
|
* This is used when switching between different accounts/identities. |
||||
|
* |
||||
|
* @param did - The DID to set as active |
||||
|
* @returns Promise that resolves when the update is complete |
||||
|
* |
||||
|
* @example |
||||
|
* ```typescript
|
||||
|
* await this.updateActiveDid('did:example:123'); |
||||
|
* ``` |
||||
|
*/ |
||||
|
async updateActiveDid(did: string): Promise<void> { |
||||
|
await this.dbExec( |
||||
|
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1", |
||||
|
[did], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get the currently active DID from the active_identity table. |
||||
|
* |
||||
|
* Retrieves the active DID that represents the currently selected |
||||
|
* account/identity. This is used throughout the application to |
||||
|
* ensure operations are performed on the correct account. |
||||
|
* |
||||
|
* @returns Promise resolving to object containing the active DID |
||||
|
* |
||||
|
* @example |
||||
|
* ```typescript
|
||||
|
* const { activeDid } = await this.getActiveIdentity(); |
||||
|
* console.log('Current active DID:', activeDid); |
||||
|
* ``` |
||||
|
*/ |
||||
|
async getActiveIdentity(): Promise<{ activeDid: string }> { |
||||
|
const result = (await this.dbQuery( |
||||
|
"SELECT activeDid FROM active_identity WHERE id = 1", |
||||
|
)) as QueryExecResult; |
||||
|
return { |
||||
|
activeDid: (result?.values?.[0]?.[0] as string) || "", |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Insert a new DID into the settings table with default values. |
||||
|
* |
||||
|
* Creates a new settings record for a DID with default configuration |
||||
|
* values. Uses INSERT OR REPLACE to handle cases where settings |
||||
|
* already exist for the DID. |
||||
|
* |
||||
|
* @param did - The DID to create settings for |
||||
|
* @returns Promise that resolves when settings are created |
||||
|
* |
||||
|
* @example |
||||
|
* ```typescript
|
||||
|
* await this.insertNewDidIntoSettings('did:example:123'); |
||||
|
* ``` |
||||
|
*/ |
||||
|
async insertNewDidIntoSettings(did: string): Promise<void> { |
||||
|
// Import constants dynamically to avoid circular dependencies
|
||||
|
const { DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER } = |
||||
|
await import("@/constants/app"); |
||||
|
|
||||
|
// Use INSERT OR REPLACE to handle case where settings already exist for this DID
|
||||
|
// This prevents duplicate accountDid entries and ensures data integrity
|
||||
|
await this.dbExec( |
||||
|
"INSERT OR REPLACE INTO settings (accountDid, finishedOnboarding, apiServer, partnerApiServer) VALUES (?, ?, ?, ?)", |
||||
|
[did, false, DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Update settings for a specific DID. |
||||
|
* |
||||
|
* Updates settings for a particular DID rather than the active one. |
||||
|
* This is useful for bulk operations or when managing multiple accounts. |
||||
|
* |
||||
|
* @param did - The DID to update settings for |
||||
|
* @param settings - Object containing the settings to update |
||||
|
* @returns Promise that resolves when settings are updated |
||||
|
* |
||||
|
* @example |
||||
|
* ```typescript
|
||||
|
* await this.updateDidSpecificSettings('did:example:123', { |
||||
|
* theme: 'light', |
||||
|
* notifications: false |
||||
|
* }); |
||||
|
* ``` |
||||
|
*/ |
||||
|
async updateDidSpecificSettings( |
||||
|
did: string, |
||||
|
settings: Record<string, unknown>, |
||||
|
): Promise<void> { |
||||
|
const keys = Object.keys(settings); |
||||
|
const setClause = keys.map((key) => `${key} = ?`).join(", "); |
||||
|
const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`; |
||||
|
const params = [...keys.map((key) => settings[key]), did]; |
||||
|
await this.dbExec(sql, params); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Retrieve settings for the currently active account. |
||||
|
* |
||||
|
* Gets the active DID and retrieves all settings for that account. |
||||
|
* Excludes the 'id' column from the returned settings object. |
||||
|
* |
||||
|
* @returns Promise resolving to settings object or null if no active DID |
||||
|
* |
||||
|
* @example |
||||
|
* ```typescript
|
||||
|
* const settings = await this.retrieveSettingsForActiveAccount(); |
||||
|
* if (settings) { |
||||
|
* console.log('Theme:', settings.theme); |
||||
|
* console.log('Notifications:', settings.notifications); |
||||
|
* } |
||||
|
* ``` |
||||
|
*/ |
||||
|
async retrieveSettingsForActiveAccount(): Promise<Record< |
||||
|
string, |
||||
|
unknown |
||||
|
> | null> { |
||||
|
// Get current active DID from active_identity table
|
||||
|
const activeIdentity = await this.getActiveIdentity(); |
||||
|
const activeDid = activeIdentity.activeDid; |
||||
|
|
||||
|
if (!activeDid) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
const result = (await this.dbQuery( |
||||
|
"SELECT * FROM settings WHERE accountDid = ?", |
||||
|
[activeDid], |
||||
|
)) as QueryExecResult; |
||||
|
if (result?.values?.[0]) { |
||||
|
// Convert the row to an object
|
||||
|
const row = result.values[0]; |
||||
|
const columns = result.columns || []; |
||||
|
const settings: Record<string, unknown> = {}; |
||||
|
|
||||
|
columns.forEach((column: string, index: number) => { |
||||
|
if (column !== "id") { |
||||
|
// Exclude the id column
|
||||
|
settings[column] = row[index]; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return settings; |
||||
|
} |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
// Abstract methods that must be implemented by platform-specific services
|
||||
|
|
||||
|
/** |
||||
|
* Execute a database query (SELECT operations). |
||||
|
* |
||||
|
* @abstract |
||||
|
* @param sql - SQL query string |
||||
|
* @param params - Optional parameters for prepared statements |
||||
|
* @returns Promise resolving to query results |
||||
|
*/ |
||||
|
abstract dbQuery(sql: string, params?: unknown[]): Promise<unknown>; |
||||
|
|
||||
|
/** |
||||
|
* Execute a database statement (INSERT, UPDATE, DELETE operations). |
||||
|
* |
||||
|
* @abstract |
||||
|
* @param sql - SQL statement string |
||||
|
* @param params - Optional parameters for prepared statements |
||||
|
* @returns Promise resolving to execution results |
||||
|
*/ |
||||
|
abstract dbExec(sql: string, params?: unknown[]): Promise<unknown>; |
||||
|
} |
||||
Loading…
Reference in new issue