import * as plugins from '../plugins.js'; /** * UserInvitation represents an invitation to join one or more organizations. * * Key characteristics: * - Unique by email (multiple orgs can share the same invitation) * - Converts to real User on registration * - Can fold into existing user if they add the email as secondary * - Auto-expires after 90 days */ @plugins.smartdata.Manager() export class UserInvitation extends plugins.smartdata.SmartDataDbDoc< UserInvitation, plugins.idpInterfaces.data.IUserInvitation > { // STATIC public static readonly EXPIRY_DAYS = 90; public static generateToken(): string { return plugins.smartunique.shortId() + '-' + plugins.smartunique.shortId(); } public static async createNewInvitation( email: string, organizationId: string, invitedByUserId: string, roles: string[] ): Promise { const invitation = new UserInvitation(); invitation.id = plugins.smartunique.shortId(); const now = Date.now(); const expiresAt = now + (UserInvitation.EXPIRY_DAYS * 24 * 60 * 60 * 1000); invitation.data = { email: email.toLowerCase().trim(), token: UserInvitation.generateToken(), status: 'pending', createdAt: now, expiresAt: expiresAt, organizationRefs: [{ organizationId, invitedByUserId, invitedAt: now, roles, }], }; await invitation.save(); return invitation; } // INSTANCE @plugins.smartdata.unI() id: string; @plugins.smartdata.svDb() public data: plugins.idpInterfaces.data.IUserInvitation['data']; constructor() { super(); } /** * Add another organization to this invitation */ public async addOrganization( organizationId: string, invitedByUserId: string, roles: string[] ): Promise { // Check if org already exists const existingRef = this.data.organizationRefs.find( ref => ref.organizationId === organizationId ); if (existingRef) { // Update roles for existing org ref existingRef.roles = roles; existingRef.invitedAt = Date.now(); existingRef.invitedByUserId = invitedByUserId; } else { // Add new org ref this.data.organizationRefs.push({ organizationId, invitedByUserId, invitedAt: Date.now(), roles, }); } await this.save(); } /** * Remove an organization from this invitation */ public async removeOrganization(organizationId: string): Promise { this.data.organizationRefs = this.data.organizationRefs.filter( ref => ref.organizationId !== organizationId ); // If no more org refs, cancel the invitation if (this.data.organizationRefs.length === 0) { this.data.status = 'cancelled'; } await this.save(); } /** * Check if invitation is expired */ public isExpired(): boolean { return Date.now() > this.data.expiresAt || this.data.status === 'expired'; } /** * Mark invitation as accepted and record the user ID */ public async accept(userId: string): Promise { this.data.status = 'accepted'; this.data.acceptedAt = Date.now(); this.data.convertedToUserId = userId; await this.save(); } /** * Regenerate token and extend expiry (for resend) */ public async regenerateToken(): Promise { this.data.token = UserInvitation.generateToken(); this.data.expiresAt = Date.now() + (UserInvitation.EXPIRY_DAYS * 24 * 60 * 60 * 1000); await this.save(); } }