Complete InviteOneView.vue Enhanced Triple Migration Pattern with human validation

- Database migration: databaseUtil → PlatformServiceMixin methods
- SQL abstraction: Raw contact insertion → $insertContact() service method
- Notification migration: 7 patterns → helper system + constants
- Template streamlining: 5 computed properties + helper methods added
- Human testing: Complete invitation lifecycle validated
- Time: 9m 5s (50% faster than estimate)
- Project: 43% complete (40/92 components migrated)
This commit is contained in:
Matthew Raymer
2025-07-08 10:37:05 +00:00
parent 1eb14ca379
commit 562740ef5b
6 changed files with 655 additions and 136 deletions

View File

@@ -68,11 +68,8 @@
>
<td>
<span
v-if="
!invite.redeemedAt &&
invite.expiresAt > new Date().toISOString()
"
class="text-center text-blue-500 cursor-pointer"
v-if="isInviteActive(invite)"
:class="activeInviteClass"
:title="inviteLink(invite.jwt)"
@click="
copyInviteAndNotify(invite.inviteIdentifier, invite.jwt)
@@ -82,13 +79,13 @@
</span>
<span
v-else
class="text-center text-slate-500 cursor-pointer"
:class="inactiveInviteClass"
:title="invite.inviteIdentifier"
@click="
showInvite(
invite.inviteIdentifier,
!!invite.redeemedAt,
invite.expiresAt < new Date().toISOString(),
isInviteExpired(invite),
)
"
>
@@ -99,10 +96,10 @@
{{ invite.notes }}
</td>
<td class="text-center">
{{ invite.redeemedAt ? "" : invite.expiresAt.substring(0, 10) }}
{{ formatExpirationDate(invite) }}
</td>
<td class="text-center">
{{ invite.redeemedAt?.substring(0, 10) }}
{{ formatRedemptionDate(invite) }}
<br />
{{ getTruncatedRedeemedBy(invite.redeemedBy) }}
<br />
@@ -140,10 +137,40 @@ import TopMessage from "../components/TopMessage.vue";
import InviteDialog from "../components/InviteDialog.vue";
import { APP_SERVER, AppString, NotificationIface } from "../constants/app";
import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { createInviteJwt, getHeaders } from "../libs/endorserServer";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import {
NOTIFY_INVITE_LOAD_ERROR,
NOTIFY_INVITE_DELETED,
createInviteLinkCopyMessage,
createInviteIdCopyMessage,
createContactAddedMessage,
createInviteDeleteConfirmMessage,
} from "@/constants/notifications";
/**
* InviteOneView Component
*
* Manages user invitations with comprehensive invite lifecycle management.
* Handles creation, tracking, deletion, and redemption of invitations.
* Integrates with contact management for redeemed invitations.
*
* Key features:
* - Invitation lifecycle management (create, track, delete)
* - Contact integration for redeemed invitations
* - Link generation and clipboard functionality
* - Comprehensive error handling and user feedback
* - Expiration and status tracking
*
* Database Operations:
* - Account settings retrieval via PlatformServiceMixin
* - Contact queries and management via mixin methods
* - Contact insertion for redeemed invitations
*
* Migration Status: Phase 1 Complete - Database patterns modernized
*/
interface Invite {
inviteIdentifier: string;
@@ -156,6 +183,7 @@ interface Invite {
@Component({
components: { ContactNameDialog, QuickNav, TopMessage, InviteDialog },
mixins: [PlatformServiceMixin],
})
export default class InviteOneView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@@ -168,9 +196,90 @@ export default class InviteOneView extends Vue {
isRegistered: boolean = false;
showAppleWarning = false;
notify!: ReturnType<typeof createNotifyHelpers>;
/**
* Initializes notification helpers
*/
created() {
this.notify = createNotifyHelpers(this.$notify);
}
// =================================================
// COMPUTED PROPERTIES
// =================================================
/**
* CSS classes for active invite links
*/
get activeInviteClass(): string {
return "text-center text-blue-500 cursor-pointer";
}
/**
* CSS classes for inactive invite links
*/
get inactiveInviteClass(): string {
return "text-center text-slate-500 cursor-pointer";
}
// =================================================
// HELPER METHODS
// =================================================
/**
* Checks if an invite is currently active (not redeemed and not expired)
* @param invite - The invite to check
* @returns True if invite is active
*/
isInviteActive(invite: Invite): boolean {
return !invite.redeemedAt && invite.expiresAt > new Date().toISOString();
}
/**
* Checks if an invite has expired
* @param invite - The invite to check
* @returns True if invite is expired
*/
isInviteExpired(invite: Invite): boolean {
return invite.expiresAt < new Date().toISOString();
}
/**
* Formats expiration date for display
* @param invite - The invite to format
* @returns Formatted date string or empty if redeemed
*/
formatExpirationDate(invite: Invite): string {
return invite.redeemedAt ? "" : invite.expiresAt.substring(0, 10);
}
/**
* Formats redemption date for display
* @param invite - The invite to format
* @returns Formatted date string or empty if not redeemed
*/
formatRedemptionDate(invite: Invite): string {
return invite.redeemedAt?.substring(0, 10) || "";
}
// =================================================
// LIFECYCLE METHODS
// =================================================
/**
* Initializes component with user invitations and contact data
*
* Workflow:
* 1. Retrieves account settings via PlatformServiceMixin
* 2. Loads user invitations from server API
* 3. Loads contact data for redeemed invitations
* 4. Maps redeemed contacts for display
*/
async mounted() {
try {
const settings = await databaseUtil.retrieveSettingsForActiveAccount();
// Use PlatformServiceMixin for account settings
const settings = await this.$accountSettings();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.isRegistered = !!settings.isRegistered;
@@ -182,15 +291,12 @@ export default class InviteOneView extends Vue {
);
this.invites = response.data.data;
const platformService = PlatformServiceFactory.getInstance();
const queryResult = await platformService.dbQuery(
"SELECT * FROM contacts",
);
const baseContacts = databaseUtil.mapQueryResultToValues(
queryResult,
) as unknown as Contact[];
// Use PlatformServiceMixin for contact retrieval
const allContacts = await this.$getAllContacts();
// Map redeemed contacts for display
for (const invite of this.invites) {
const contact = baseContacts.find(
const contact = allContacts.find(
(contact) => contact.did === invite.redeemedBy,
);
if (contact && invite.redeemedBy) {
@@ -199,15 +305,7 @@ export default class InviteOneView extends Vue {
}
} catch (error) {
logger.error("Error fetching invites:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Load Error",
text: "Got an error loading your invites.",
},
5000,
);
this.notify.error(NOTIFY_INVITE_LOAD_ERROR.message, TIMEOUTS.LONG);
}
}
@@ -233,33 +331,14 @@ export default class InviteOneView extends Vue {
copyInviteAndNotify(inviteId: string, jwt: string) {
useClipboard().copy(this.inviteLink(jwt));
this.$notify(
{
group: "alert",
type: "success",
title: "Copied",
text: "Your clipboard now contains the link for invite " + inviteId,
},
5000,
);
this.notify.success(createInviteLinkCopyMessage(inviteId), TIMEOUTS.LONG);
}
showInvite(inviteId: string, redeemed: boolean, expired: boolean) {
let message = `Your clipboard now contains the invite ID ${inviteId}`;
if (redeemed) {
message += " (This invite has been used.)";
} else if (expired) {
message += " (This invite has expired.)";
}
useClipboard().copy(inviteId);
this.$notify(
{
group: "alert",
type: "success",
title: "Copied",
text: message,
},
5000,
this.notify.success(
createInviteIdCopyMessage(inviteId, redeemed, expired),
TIMEOUTS.LONG,
);
}
@@ -274,15 +353,7 @@ export default class InviteOneView extends Vue {
message = error.response.data.error;
}
}
this.$notify(
{
group: "alert",
type: "danger",
title: title,
text: message,
},
5000,
);
this.notify.error(message, TIMEOUTS.LONG);
}
async createInvite() {
@@ -335,33 +406,31 @@ export default class InviteOneView extends Vue {
);
}
/**
* Adds a new contact from redeemed invitation
*
* @param did - DID of the person who redeemed the invitation
* @param notes - Notes from the original invitation
*/
async addNewContact(did: string, notes: string) {
(this.$refs.contactNameDialog as ContactNameDialog).open(
"To Whom Did You Send The Invite?",
"Their name will be added to your contact list.",
async (name) => {
// the person obviously registered themselves and this user already granted visibility, so we just add them
// The person registered and user granted visibility, so add them
const contact = {
did: did,
name: name,
registered: true,
};
const platformService = PlatformServiceFactory.getInstance();
const columns = Object.keys(contact);
const values = Object.values(contact);
const placeholders = values.map(() => "?").join(", ");
const sql = `INSERT INTO contacts (${columns.join(", ")}) VALUES (${placeholders})`;
await platformService.dbExec(sql, values);
// Use PlatformServiceMixin service method for contact insertion
await this.$insertContact(contact);
this.contactsRedeemed[did] = contact;
this.$notify(
{
group: "alert",
type: "success",
title: "Contact Added",
text: `${name} has been added to your contacts.`,
},
3000,
this.notify.success(
createContactAddedMessage(name || "Contact"),
TIMEOUTS.STANDARD,
);
},
() => {},
@@ -370,45 +439,28 @@ export default class InviteOneView extends Vue {
}
deleteInvite(inviteId: string, notes: string) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Delete Invite?",
text: `Are you sure you want to erase the invite for "${notes}"? (There is no undo.)`,
onYes: async () => {
const headers = await getHeaders(this.activeDid);
try {
const result = await axios.delete(
this.apiServer + "/api/userUtil/invite/" + inviteId,
{ headers },
);
if (result.status !== 204) {
throw result.data;
}
this.invites = this.invites.filter(
(invite) => invite.inviteIdentifier !== inviteId,
);
this.$notify(
{
group: "alert",
type: "success",
title: "Deleted",
text: "Invite deleted.",
},
3000,
);
} catch (e) {
this.lookForErrorAndNotify(
e,
"Error Deleting Invite",
"Got an error deleting your invite.",
);
}
},
},
-1,
);
this.notify.confirm(createInviteDeleteConfirmMessage(notes), async () => {
const headers = await getHeaders(this.activeDid);
try {
const result = await axios.delete(
this.apiServer + "/api/userUtil/invite/" + inviteId,
{ headers },
);
if (result.status !== 204) {
throw result.data;
}
this.invites = this.invites.filter(
(invite) => invite.inviteIdentifier !== inviteId,
);
this.notify.success(NOTIFY_INVITE_DELETED.message, TIMEOUTS.STANDARD);
} catch (e) {
this.lookForErrorAndNotify(
e,
"Error Deleting Invite",
"Got an error deleting your invite.",
);
}
});
}
}
</script>