feat: implement invite onboarding and dynamic ticket fields

This commit is contained in:
esdrasrenan 2025-10-05 21:47:28 -03:00
parent 29a647f6c6
commit f24a7f68ca
34 changed files with 2240 additions and 97 deletions

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest"
import { computeInviteStatus, normalizeInvite, normalizeRoleOption } from "@/server/invite-utils"
const baseInvite = {
id: "invite-1",
email: "user@sistema.dev",
name: "Usuário Teste",
role: "agent",
tenantId: "tenant-1",
token: "token",
status: "pending",
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
updatedAt: new Date(),
createdById: "admin-1",
acceptedAt: null,
acceptedById: null,
revokedAt: null,
revokedById: null,
revokedReason: null,
} as const
describe("invite-utils", () => {
it("computes pending status when invite is valid", () => {
const status = computeInviteStatus(baseInvite)
expect(status).toBe("pending")
})
it("computes expired status when invite is past expiration", () => {
const expiredInvite = { ...baseInvite, expiresAt: new Date(Date.now() - 1) }
const status = computeInviteStatus(expiredInvite)
expect(status).toBe("expired")
})
it("honors revoked status regardless of expiration", () => {
const revoked = { ...baseInvite, status: "revoked" as const, expiresAt: new Date(Date.now() - 1) }
const status = computeInviteStatus(revoked)
expect(status).toBe("revoked")
})
it("normalizes invite payload with defaults", () => {
const normalized = normalizeInvite({ ...baseInvite, events: [] })
expect(normalized.email).toBe(baseInvite.email)
expect(normalized.status).toBe("pending")
expect(normalized.events).toHaveLength(0)
})
it("normalizes role falling back to agent", () => {
expect(normalizeRoleOption("admin")).toBe("admin")
expect(normalizeRoleOption("unknown")).toBe("agent")
})
})

View file

@ -0,0 +1,98 @@
import type { AuthInvite, AuthInviteEvent } from "@prisma/client"
import { ROLE_OPTIONS, type RoleOption, normalizeRole } from "@/lib/authz"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { env } from "@/lib/env"
export type InviteStatus = "pending" | "accepted" | "revoked" | "expired"
export type InviteWithEvents = AuthInvite & {
events: AuthInviteEvent[]
}
export type InviteEventPayload = {
id: string
type: string
createdAt: string
actorId: string | null
payload: unknown
}
export type NormalizedInvite = {
id: string
email: string
name: string | null
role: RoleOption
tenantId: string
status: InviteStatus
token: string
inviteUrl: string
expiresAt: string
createdAt: string
createdById: string | null
acceptedAt: string | null
acceptedById: string | null
revokedAt: string | null
revokedById: string | null
revokedReason: string | null
events: InviteEventPayload[]
}
const DEFAULT_APP_URL = env.NEXT_PUBLIC_APP_URL ?? env.BETTER_AUTH_URL ?? "http://localhost:3000"
export function computeInviteStatus(invite: AuthInvite, now: Date = new Date()): InviteStatus {
if (invite.status === "revoked") return "revoked"
if (invite.status === "accepted") return "accepted"
if (invite.status === "expired") return "expired"
if (invite.expiresAt.getTime() <= now.getTime()) {
return "expired"
}
return "pending"
}
export function buildInviteUrl(token: string) {
const base = DEFAULT_APP_URL.endsWith("/") ? DEFAULT_APP_URL.slice(0, -1) : DEFAULT_APP_URL
return `${base}/invite/${token}`
}
export function normalizeRoleOption(role?: string | null): RoleOption {
const normalized = normalizeRole(role)
if (normalized && (ROLE_OPTIONS as readonly string[]).includes(normalized)) {
return normalized as RoleOption
}
return "agent"
}
export function normalizeInvite(invite: InviteWithEvents, now: Date = new Date()): NormalizedInvite {
const status = computeInviteStatus(invite, now)
const inviteUrl = buildInviteUrl(invite.token)
return {
id: invite.id,
email: invite.email,
name: invite.name ?? null,
role: normalizeRoleOption(invite.role),
tenantId: invite.tenantId ?? DEFAULT_TENANT_ID,
status,
token: invite.token,
inviteUrl,
expiresAt: invite.expiresAt.toISOString(),
createdAt: invite.createdAt.toISOString(),
createdById: invite.createdById ?? null,
acceptedAt: invite.acceptedAt ? invite.acceptedAt.toISOString() : null,
acceptedById: invite.acceptedById ?? null,
revokedAt: invite.revokedAt ? invite.revokedAt.toISOString() : null,
revokedById: invite.revokedById ?? null,
revokedReason: invite.revokedReason ?? null,
events: invite.events
.slice()
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.map((event) => ({
id: event.id,
type: event.type,
createdAt: event.createdAt.toISOString(),
actorId: event.actorId ?? null,
payload: event.payload,
})),
}
}