/**
 * Invite lifecycle service (backed by the `actuate_invites` table).
 *
 * The `Invite` row is the authoritative record of an invitation — it drives the
 * Users admin UI (pending list, resend/cancel/extend/change-role) and seat
 * accounting. To keep a working, secure acceptance flow without a separate
 * invitee page, creating an invite also provisions a placeholder user with an
 * unusable password (the proven Batch-2 mechanism) and issues a single-use,
 * hashed password-reset token. The invitee sets their password via the existing
 * `/reset-password` page; completing that flips the invite to ACCEPTED (see
 * `markInviteAcceptedForEmail`, called from `executePasswordReset`).
 *
 * Members are de-duplicated against pending invites by email so a not-yet-
 * accepted invite is shown only as a pending row, never double-counted.
 */
import { randomBytes } from 'node:crypto';
import { hashPassword } from './password.js';
import { generateResetToken } from './reset.js';
import { userInvitationEmailTemplate } from './invite-email.js';
import { resolveEmailSenderAsync } from '../email/resolve.js';
import { normalizeAssignableRole, toDisplayRole } from './roles.js';
/** Invitations are valid for a week by default — a person has to act. */
export const INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
export const VALID_EXPIRY_DAYS = [7, 14, 30];
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function expiryDays(input) {
    const v = input ?? 7;
    return VALID_EXPIRY_DAYS.includes(v) ? v : 7;
}
function buildAcceptUrl(rawToken, config) {
    const base = config.siteUrl.replace(/\/$/, '');
    const adminPath = (config.adminPath ?? '/admin').replace(/\/$/, '');
    return `${base}${adminPath}/reset-password?token=${rawToken}`;
}
function serializeInvite(row) {
    return {
        id: row.id,
        email: row.email,
        role: row.role,
        roleName: toDisplayRole(row.role),
        status: row.status,
        invitedByUserId: row.invitedByUserId ?? null,
        expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : row.expiresAt,
        createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt,
        expired: new Date(row.expiresAt).getTime() < Date.now(),
    };
}
async function sendInviteEmail(email, name, role, inviteUrl, config) {
    const sender = await resolveEmailSenderAsync();
    if (!sender)
        return false;
    const tpl = userInvitationEmailTemplate({
        inviteUrl,
        userName: name,
        role,
        invitedByName: config.invitedByName,
        siteName: config.siteName,
    });
    try {
        await sender.send({ to: email, subject: tpl.subject, html: tpl.html, text: tpl.text });
        return true;
    }
    catch {
        // Best-effort: the invite + token are persisted, so the admin can share the
        // returned link manually. Don't fail the whole invite on a send error.
        return false;
    }
}
/**
 * Create an invitation. Provisions a placeholder user, persists a PENDING
 * invite + single-use reset token, and emails the accept link.
 */
export async function createInvite(db, input, config) {
    const email = input.email.toLowerCase().trim();
    if (!EMAIL_RE.test(email)) {
        return { success: false, error: 'Enter a valid email address.', code: 'INVALID_EMAIL' };
    }
    const role = normalizeAssignableRole(input.role);
    if (!role) {
        return { success: false, error: 'Select a valid role.', code: 'INVALID_ROLE' };
    }
    // Already a pending invite? Offer resend rather than a duplicate.
    const pending = await db.invite.findFirst({
        where: { email: { equals: email, mode: 'insensitive' }, status: 'PENDING' },
    });
    if (pending) {
        return {
            success: false,
            error: 'This person already has a pending invitation. Resend it instead.',
            code: 'PENDING_INVITE',
            invite: serializeInvite(pending),
        };
    }
    // An existing, already-accepted user can't be re-invited.
    const existingUser = await db.user.findFirst({
        where: { email: { equals: email, mode: 'insensitive' } },
    });
    if (existingUser) {
        return {
            success: false,
            error: 'A user with this email already exists.',
            code: 'DUPLICATE_USER',
        };
    }
    const name = email.split('@')[0] || email;
    const passwordHash = await hashPassword(randomBytes(32).toString('hex'));
    const expiresAt = new Date(Date.now() + expiryDays(input.expiresInDays) * 24 * 60 * 60 * 1000);
    // Placeholder user — unusable password, not yet approved/accepted.
    const user = await db.user.create({
        data: {
            email,
            name,
            role,
            passwordHash,
            isActive: true,
            isApproved: false,
            emailVerified: false,
            invitedById: config.invitedByUserId ?? null,
        },
    });
    const { raw, hash } = generateResetToken();
    await db.passwordResetToken.create({
        data: { userId: user.id, tokenHash: hash, expiresAt },
    });
    const invite = await db.invite.create({
        data: {
            email,
            role,
            status: 'PENDING',
            tokenHash: hash,
            message: input.message?.trim() || null,
            invitedByUserId: config.invitedByUserId ?? null,
            expiresAt,
        },
    });
    const inviteUrl = buildAcceptUrl(raw, config);
    const emailed = await sendInviteEmail(email, name, role, inviteUrl, config);
    return {
        success: true,
        emailed,
        inviteUrl: emailed ? undefined : inviteUrl,
        invite: serializeInvite(invite),
    };
}
/**
 * List invites, lazily flipping any past-expiry PENDING rows to EXPIRED so the
 * status is always honest without a background job.
 */
export async function listInvites(db, params = {}) {
    await expireStaleInvites(db);
    const where = {};
    if (params.status && params.status !== 'all')
        where.status = params.status.toUpperCase();
    if (params.search)
        where.email = { contains: params.search.trim(), mode: 'insensitive' };
    const rows = await db.invite.findMany({ where, orderBy: { createdAt: 'desc' } });
    return rows.map(serializeInvite);
}
/** Flip PENDING invites whose expiry has passed to EXPIRED. Returns the count. */
export async function expireStaleInvites(db) {
    const res = await db.invite.updateMany({
        where: { status: 'PENDING', expiresAt: { lt: new Date() } },
        data: { status: 'EXPIRED' },
    });
    return res.count ?? 0;
}
/** Resend (or revive an expired) invite: fresh token, reset expiry, re-email. */
export async function resendInvite(db, inviteId, config) {
    const invite = await db.invite.findUnique({ where: { id: inviteId } });
    if (!invite)
        return { success: false, error: 'Invite not found.', code: 'NOT_FOUND' };
    if (invite.status === 'ACCEPTED') {
        return { success: false, error: 'This invitation was already accepted.' };
    }
    if (invite.status === 'CANCELLED') {
        return { success: false, error: 'This invitation was cancelled.' };
    }
    const user = await db.user.findFirst({
        where: { email: { equals: invite.email, mode: 'insensitive' } },
    });
    const expiresAt = new Date(Date.now() + INVITE_EXPIRY_MS);
    const { raw, hash } = generateResetToken();
    if (user) {
        await db.passwordResetToken.updateMany({
            where: { userId: user.id, usedAt: null },
            data: { usedAt: new Date() },
        });
        await db.passwordResetToken.create({ data: { userId: user.id, tokenHash: hash, expiresAt } });
    }
    const updated = await db.invite.update({
        where: { id: inviteId },
        data: { status: 'PENDING', tokenHash: hash, expiresAt },
    });
    const inviteUrl = buildAcceptUrl(raw, config);
    const emailed = await sendInviteEmail(invite.email, user?.name ?? invite.email, invite.role, inviteUrl, config);
    return {
        success: true,
        emailed,
        inviteUrl: emailed ? undefined : inviteUrl,
        invite: serializeInvite(updated),
    };
}
/** Cancel an invite and deactivate its placeholder user so the link is dead. */
export async function cancelInvite(db, inviteId) {
    const invite = await db.invite.findUnique({ where: { id: inviteId } });
    if (!invite)
        return { success: false, error: 'Invite not found.', code: 'NOT_FOUND' };
    if (invite.status === 'ACCEPTED') {
        return { success: false, error: 'This invitation was already accepted.' };
    }
    const user = await db.user.findFirst({
        where: { email: { equals: invite.email, mode: 'insensitive' }, acceptedInviteAt: null },
    });
    if (user) {
        await db.passwordResetToken.updateMany({
            where: { userId: user.id, usedAt: null },
            data: { usedAt: new Date() },
        });
        await db.user.update({ where: { id: user.id }, data: { isActive: false } });
    }
    const updated = await db.invite.update({
        where: { id: inviteId },
        data: { status: 'CANCELLED', cancelledAt: new Date() },
    });
    return { success: true, invite: serializeInvite(updated) };
}
/** Change the role on a pending invite (and its placeholder user). */
export async function updateInviteRole(db, inviteId, role) {
    const normalized = normalizeAssignableRole(role);
    if (!normalized)
        return { success: false, error: 'Select a valid role.', code: 'INVALID_ROLE' };
    const invite = await db.invite.findUnique({ where: { id: inviteId } });
    if (!invite)
        return { success: false, error: 'Invite not found.', code: 'NOT_FOUND' };
    if (invite.status !== 'PENDING') {
        return { success: false, error: 'Only pending invitations can be changed.' };
    }
    await db.user.updateMany({
        where: { email: { equals: invite.email, mode: 'insensitive' }, acceptedInviteAt: null },
        data: { role: normalized },
    });
    const updated = await db.invite.update({ where: { id: inviteId }, data: { role: normalized } });
    return { success: true, invite: serializeInvite(updated) };
}
/** Extend a pending invite's expiry and re-sync its reset token expiry. */
export async function extendInvite(db, inviteId, days) {
    const invite = await db.invite.findUnique({ where: { id: inviteId } });
    if (!invite)
        return { success: false, error: 'Invite not found.', code: 'NOT_FOUND' };
    if (invite.status !== 'PENDING') {
        return { success: false, error: 'Only pending invitations can be extended.' };
    }
    const expiresAt = new Date(Date.now() + expiryDays(days) * 24 * 60 * 60 * 1000);
    const user = await db.user.findFirst({
        where: { email: { equals: invite.email, mode: 'insensitive' }, acceptedInviteAt: null },
    });
    if (user) {
        await db.passwordResetToken.updateMany({
            where: { userId: user.id, usedAt: null },
            data: { expiresAt },
        });
    }
    const updated = await db.invite.update({ where: { id: inviteId }, data: { expiresAt } });
    return { success: true, invite: serializeInvite(updated) };
}
/**
 * Mark the pending invite for an email as ACCEPTED. Called when a user sets
 * their password through the reset/accept flow. Safe to call for non-invited
 * users — it simply updates zero rows.
 */
export async function markInviteAcceptedForEmail(db, email, userId) {
    const normalized = email.toLowerCase().trim();
    const res = await db.invite.updateMany({
        where: { email: { equals: normalized, mode: 'insensitive' }, status: 'PENDING' },
        data: { status: 'ACCEPTED', acceptedAt: new Date(), acceptedUserId: userId },
    });
    return (res.count ?? 0) > 0;
}
//# sourceMappingURL=invites.js.map