Files
app/ts/reception/classes.userinvitation.ts
2025-12-04 17:45:40 +00:00

137 lines
3.5 KiB
TypeScript

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<UserInvitation> {
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<void> {
// 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<void> {
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<void> {
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<void> {
this.data.token = UserInvitation.generateToken();
this.data.expiresAt = Date.now() + (UserInvitation.EXPIRY_DAYS * 24 * 60 * 60 * 1000);
await this.save();
}
}