@@ -524,7 +838,11 @@ export default function InvitesPage() {
{visiblePendingInvites.map((invite) => {
const inviteLink = buildPendingInviteLink(invite.code, invite.isClaimInvite);
const copyKey = `pending:${invite.id}`;
- const purposeLabel = invite.isClaimInvite ? "Claim link" : "Open invite";
+ const purposeLabel = invite.isClaimInvite
+ ? "Claim link"
+ : invite.isReusable
+ ? "Family Link"
+ : "Open invite";
const claimForLabel = invite.claimMember?.name ?? "Profile unavailable";
return (
@@ -645,37 +963,72 @@ export default function InvitesPage() {
Loading invite history...
- ) : visibleHistoryInvites.length === 0 ? (
+ ) : combinedHistoryItems.length === 0 ? (
No invite history yet.
) : (
- {visibleHistoryInvites.map((invite) => (
- -
-
-
- {invite.invitedEmail ?? "No email specified"}
-
-
- Created {formatDate(invite.createdAt)}
- {invite.claimedBy
- ? ` · Accepted by ${invite.claimedBy.name ?? invite.claimedBy.email ?? "Unknown"}`
- : ""}
-
-
+ {combinedHistoryItems.map((item) => {
+ if (item.kind === "invite") {
+ return (
+ -
+
+
+ {item.claimedBy ? (
+ item.isClaimInvite ? (
+ `${formatActorName(item.claimedBy.name, item.claimedBy.email)} claimed their member profile`
+ ) : item.type === "OPEN" ? (
+ `${formatActorName(item.claimedBy.name, item.claimedBy.email)} joined via open invite`
+ ) : (
+ `${formatActorName(item.claimedBy.name, item.claimedBy.email)} claimed their invite`
+ )
+ ) : (
+ item.invitedEmail ?? "Invite updated"
+ )}
+
+
+ Created {formatDate(item.createdAt)}
+ {item.claimedBy
+ ? ` · Accepted by ${item.claimedBy.name ?? item.claimedBy.email ?? "Unknown"}`
+ : ""}
+
+
-
+ {formatStatus(item.lifecycleState)}
+
+
+ );
+ }
+
+ return (
+ -
- {formatStatus(invite.lifecycleState)}
-
-
- ))}
+
+
{item.title}
+
{item.subtitle}
+
+
+
+ Family Link
+
+
+ );
+ })}
)}
diff --git a/src/app/auth/invite/[code]/page.tsx b/src/app/auth/invite/[code]/page.tsx
index f55dda8..376b99c 100644
--- a/src/app/auth/invite/[code]/page.tsx
+++ b/src/app/auth/invite/[code]/page.tsx
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
-import { AlertCircle, UserRoundPlus } from "~/components/ui/icons";
+import { AlertCircle } from "~/components/ui/icons";
import { signIn } from "next-auth/react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
@@ -11,7 +11,7 @@ import { beginNavigationProgress } from "~/components/nav/navigation-progress";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
-import { formatFamilyDisplayName } from "~/lib/family-name";
+import { formatFamilyLockup } from "~/lib/family-name";
import { api } from "~/trpc/react";
type InvitePageState = "loading" | "valid" | "expired" | "claimed" | "revoked" | "invalid";
@@ -64,6 +64,12 @@ function mapAcceptInviteErrorMessage(message: string): string {
return "Could not accept invite. Please try again.";
}
+function getInviteIntro(isReusable: boolean) {
+ return isReusable
+ ? "Use this family invite link to join and create your account."
+ : "Use this invite link to accept and create your account.";
+}
+
function getInviteStateMessage(state: InvitePageState): string {
if (state === "expired") return "This invite has expired. Please request a new one.";
if (state === "claimed") return "This invite has already been used.";
@@ -107,6 +113,7 @@ export default function InviteAcceptancePage() {
const stateErrorMessage = queryState === "valid" || queryState === "loading"
? null
: getInviteStateMessage(queryState);
+ const isReusableInvite = inviteQuery.data?.isReusable === true;
const errorMessage =
formError ??
@@ -175,47 +182,21 @@ export default function InviteAcceptancePage() {
- You're invited to join a family
+ {isReusableInvite ? "You’re joining through a family invite link" : "You’re invited to join a family"}
- Use invite code {params.code} to accept and create your account.
+ {inviteQuery.isLoading
+ ? "Checking invite details..."
+ : inviteQuery.data
+ ? (
+ <>
+ Joining {formatFamilyLockup(inviteQuery.data.family.name)}. {getInviteIntro(isReusableInvite)}
+ >
+ )
+ : getInviteStateMessage(queryState)}
-
-
-
-
-
-
- {inviteQuery.isLoading ? (
- <>
-
Checking invite details...
-
Loading family
- >
- ) : inviteQuery.data ? (
- <>
-
Valid invite
-
{formatFamilyDisplayName(inviteQuery.data.family.name)}
- {inviteQuery.data.family.description ? (
-
- {inviteQuery.data.family.description}
-
- ) : null}
- >
- ) : (
- <>
-
Invite status
-
{queryState}
-
- {getInviteStateMessage(queryState)}
-
- >
- )}
-
-
-
-
{errorMessage ? (
diff --git a/src/components/ui/button-group.tsx b/src/components/ui/button-group.tsx
new file mode 100644
index 0000000..d755d8c
--- /dev/null
+++ b/src/components/ui/button-group.tsx
@@ -0,0 +1,83 @@
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { cn } from "~/lib/utils"
+import { Separator } from "~/components/ui/separator"
+
+const buttonGroupVariants = cva(
+ "group/button-group flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[>[data-variant=outline]]:*:data-[slot=input-group]:border-border has-[>[data-variant=outline]]:*:data-[slot=select-trigger]:border-border has-[>[data-variant=outline]]:[&>[data-slot=input-group]:has(:focus-visible)]:border-ring has-[>[data-variant=outline]]:[&>[data-slot=select-trigger]:focus-visible]:border-ring has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-4xl [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[>[data-variant=outline]]:[&>input]:border-border has-[>[data-variant=outline]]:[&>input:focus-visible]:border-ring",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-4xl!",
+ vertical:
+ "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-4xl!",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ }
+)
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot.Root : "div"
+
+ return (
+
+ )
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+}
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..9eeeb2d
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import { Separator as SeparatorPrimitive } from "radix-ui"
+
+import { cn } from "~/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx
new file mode 100644
index 0000000..e2931ae
--- /dev/null
+++ b/src/components/ui/toggle.tsx
@@ -0,0 +1,47 @@
+"use client"
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Toggle as TogglePrimitive } from "radix-ui"
+
+import { cn } from "~/lib/utils"
+
+const toggleVariants = cva(
+ "group/toggle inline-flex items-center justify-center gap-1 rounded-3xl text-sm font-medium whitespace-nowrap transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline: "border border-input bg-transparent hover:bg-muted",
+ },
+ size: {
+ default:
+ "h-9 min-w-9 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
+ sm: "h-8 min-w-8 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ lg: "h-10 min-w-10 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Toggle({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+ )
+}
+
+export { Toggle, toggleVariants }
diff --git a/src/lib/invite-constants.ts b/src/lib/invite-constants.ts
new file mode 100644
index 0000000..86ffd57
--- /dev/null
+++ b/src/lib/invite-constants.ts
@@ -0,0 +1,8 @@
+export const INVITE_DEFAULT_TTL_DAYS = 14
+export const CLAIM_DEFAULT_TTL_DAYS = 30
+
+export const INVITE_TYPES = ["OPEN", "EMAIL_BOUND"] as const
+export const INVITE_STATUSES = ["PENDING", "CLAIMED", "EXPIRED", "REVOKED"] as const
+
+export type InviteTypeValue = (typeof INVITE_TYPES)[number]
+export type InviteStatusValue = (typeof INVITE_STATUSES)[number]
diff --git a/src/lib/invite-schemas.ts b/src/lib/invite-schemas.ts
index d60ea1a..b32fcb8 100644
--- a/src/lib/invite-schemas.ts
+++ b/src/lib/invite-schemas.ts
@@ -1,7 +1,12 @@
import { z } from "zod"
import { normalizeEmail } from "~/lib/email"
-import { INVITE_DEFAULT_TTL_DAYS, INVITE_TYPES, CLAIM_DEFAULT_TTL_DAYS } from "~/lib/invite"
+import {
+ INVITE_DEFAULT_TTL_DAYS,
+ INVITE_TYPES,
+ INVITE_STATUSES,
+ CLAIM_DEFAULT_TTL_DAYS,
+} from "~/lib/invite-constants"
export const inviteCodeSchema = z
.string()
@@ -62,6 +67,35 @@ export const retryEmailSendInputSchema = z.object({
export type RetryEmailSendInput = z.infer
+export const reusableInviteLifecycleStateSchema = z.enum(["valid", "revoked", "invalid"])
+
+export const getActiveReusableInviteInputSchema = z.object({
+ familyId: z.string().cuid(),
+})
+
+export const resetReusableInviteInputSchema = z.object({
+ familyId: z.string().cuid(),
+})
+
+export const reusableInviteSummarySchema = z.object({
+ id: z.string(),
+ code: z.string(),
+ familyId: z.string(),
+ isReusable: z.literal(true),
+ status: z.enum(INVITE_STATUSES),
+ lifecycleState: reusableInviteLifecycleStateSchema,
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ revokedAt: z.date().nullable(),
+ rotatedFromInviteId: z.string().nullable(),
+ useCount: z.number().int().nonnegative(),
+ lastUsedAt: z.date().nullable(),
+})
+
+export type GetActiveReusableInviteInput = z.infer
+export type ResetReusableInviteInput = z.infer
+export type ReusableInviteSummary = z.infer
+
// ─── Claim schemas ────────────────────────────────────────────────────────────
const internalMediaUrlSchema = z
diff --git a/src/lib/invite.ts b/src/lib/invite.ts
index 9c9c481..f66276d 100644
--- a/src/lib/invite.ts
+++ b/src/lib/invite.ts
@@ -1,17 +1,25 @@
import { randomBytes } from "node:crypto"
import { emailsMatch, normalizeEmail } from "~/lib/email"
-
-export const INVITE_DEFAULT_TTL_DAYS = 14
-export const CLAIM_DEFAULT_TTL_DAYS = 30
-
-export const INVITE_TYPES = ["OPEN", "EMAIL_BOUND"] as const
-export const INVITE_STATUSES = ["PENDING", "CLAIMED", "EXPIRED", "REVOKED"] as const
-
-export type InviteTypeValue = (typeof INVITE_TYPES)[number]
-export type InviteStatusValue = (typeof INVITE_STATUSES)[number]
+import {
+ CLAIM_DEFAULT_TTL_DAYS,
+ INVITE_DEFAULT_TTL_DAYS,
+ INVITE_STATUSES,
+ INVITE_TYPES,
+ type InviteStatusValue,
+ type InviteTypeValue,
+} from "~/lib/invite-constants"
+
+export {
+ CLAIM_DEFAULT_TTL_DAYS,
+ INVITE_DEFAULT_TTL_DAYS,
+ INVITE_STATUSES,
+ INVITE_TYPES,
+}
+export type { InviteStatusValue, InviteTypeValue }
export type InviteLifecycleState = "valid" | "expired" | "claimed" | "revoked"
+export type ReusableInviteLifecycleState = "valid" | "revoked" | "invalid"
/** All states a looked-up claim link can be in, including "not found". */
export type ClaimLifecycleState = "valid" | "expired" | "claimed" | "revoked" | "invalid"
@@ -23,6 +31,12 @@ export type InviteLifecycleRecord = {
revokedAt: Date | null
}
+export type ReusableInviteLifecycleRecord = {
+ isReusable: boolean
+ status: InviteStatusValue
+ revokedAt: Date | null
+}
+
export type InviteBindingRecord = {
type: InviteTypeValue
invitedEmail: string | null
@@ -91,6 +105,32 @@ export function isInviteUsable(
return invite.status === "PENDING" && getInviteLifecycleState(invite, now) === "valid"
}
+export function isReusableInvite(invite: Pick): boolean {
+ return invite.isReusable
+}
+
+export function getReusableInviteLifecycleState(
+ invite: ReusableInviteLifecycleRecord | null,
+): ReusableInviteLifecycleState {
+ if (!invite?.isReusable) {
+ return "invalid"
+ }
+
+ if (invite.revokedAt || invite.status === "REVOKED") {
+ return "revoked"
+ }
+
+ if (invite.status !== "PENDING") {
+ return "invalid"
+ }
+
+ return "valid"
+}
+
+export function isReusableInviteUsable(invite: ReusableInviteLifecycleRecord | null): boolean {
+ return getReusableInviteLifecycleState(invite) === "valid"
+}
+
export function validateInviteEmailBinding(
invite: InviteBindingRecord,
email: string,
diff --git a/src/server/api/routers/invite.ts b/src/server/api/routers/invite.ts
index a50c1f5..ee89be9 100644
--- a/src/server/api/routers/invite.ts
+++ b/src/server/api/routers/invite.ts
@@ -13,14 +13,19 @@ import {
generateInviteCode,
getInviteExpiryDate,
getInviteLifecycleState,
+ getReusableInviteLifecycleState,
isInviteUsable,
+ isReusableInvite,
+ isReusableInviteUsable,
validateInviteEmailBinding,
} from "~/lib/invite"
import {
+ getActiveReusableInviteInputSchema,
inviteLookupInputSchema,
inviteAcceptInputSchema,
inviteCreateInputSchema,
inviteRevokeInputSchema,
+ resetReusableInviteInputSchema,
retryEmailSendInputSchema,
} from "~/lib/invite-schemas"
import { normalizeEmail } from "~/lib/email"
@@ -44,7 +49,88 @@ import {
getClaimedAdminMemberIds,
} from "~/server/notifications"
+const REUSABLE_INVITE_FUTURE_EXPIRY = new Date("2999-12-31T00:00:00.000Z")
+
+function buildReusableInviteSummary(invite: {
+ id: string
+ code: string
+ familyId: string
+ isReusable: boolean
+ status: "PENDING" | "CLAIMED" | "EXPIRED" | "REVOKED"
+ createdAt: Date
+ updatedAt: Date
+ revokedAt: Date | null
+ rotatedFromInviteId: string | null
+ useCount: number
+ lastUsedAt: Date | null
+}) {
+ return {
+ id: invite.id,
+ code: invite.code,
+ familyId: invite.familyId,
+ isReusable: true as const,
+ status: invite.status,
+ lifecycleState: getReusableInviteLifecycleState(invite),
+ createdAt: invite.createdAt,
+ updatedAt: invite.updatedAt,
+ revokedAt: invite.revokedAt,
+ rotatedFromInviteId: invite.rotatedFromInviteId,
+ useCount: invite.useCount,
+ lastUsedAt: invite.lastUsedAt,
+ }
+}
+
export const inviteRouter = createTRPCRouter({
+ getActiveReusableInvite: protectedProcedure
+ .input(getActiveReusableInviteInputSchema)
+ .query(async ({ ctx, input }) => {
+ const membership = await ctx.db.familyMember.findUnique({
+ where: {
+ familyId_userId: {
+ familyId: input.familyId,
+ userId: ctx.session.user.id,
+ },
+ },
+ })
+
+ if (!membership || (membership.role !== "ADMIN" && membership.role !== "OWNER")) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You do not have permission to view family invite links for this family",
+ })
+ }
+
+ const activeReusableInvite = await ctx.db.invite.findFirst({
+ where: {
+ familyId: input.familyId,
+ isReusable: true,
+ claimMemberId: null,
+ status: "PENDING",
+ revokedAt: null,
+ },
+ orderBy: { createdAt: "desc" },
+ })
+
+ if (activeReusableInvite) {
+ return buildReusableInviteSummary(activeReusableInvite)
+ }
+
+ const latestReusableInvite = await ctx.db.invite.findFirst({
+ where: {
+ familyId: input.familyId,
+ isReusable: true,
+ claimMemberId: null,
+ },
+ orderBy: { createdAt: "desc" },
+ })
+
+ if (!latestReusableInvite) {
+ return null
+ }
+
+ return buildReusableInviteSummary(latestReusableInvite)
+ }),
+
/**
* Public query: Get invite details by code for pre-acceptance viewing.
* Returns family info and invite metadata but no sensitive claim history.
@@ -74,12 +160,14 @@ export const inviteRouter = createTRPCRouter({
})
}
- const state = getInviteLifecycleState({
- status: invite.status,
- expiresAt: invite.expiresAt,
- claimedAt: invite.claimedAt,
- revokedAt: invite.revokedAt,
- })
+ const state = isReusableInvite(invite)
+ ? getReusableInviteLifecycleState(invite)
+ : getInviteLifecycleState({
+ status: invite.status,
+ expiresAt: invite.expiresAt,
+ claimedAt: invite.claimedAt,
+ revokedAt: invite.revokedAt,
+ })
// Map lifecycle state to user-facing error codes
if (state === "revoked") {
@@ -103,9 +191,17 @@ export const inviteRouter = createTRPCRouter({
})
}
+ if (state === "invalid") {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "This invite is not valid",
+ })
+ }
+
return {
id: invite.id,
code: invite.code,
+ isReusable: invite.isReusable,
family: {
id: invite.family.id,
name: invite.family.name,
@@ -147,7 +243,22 @@ export const inviteRouter = createTRPCRouter({
}
// Check invite usability
- if (!isInviteUsable(invite)) {
+ if (isReusableInvite(invite)) {
+ if (!isReusableInviteUsable(invite)) {
+ const state = getReusableInviteLifecycleState(invite)
+ if (state === "revoked") {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "This invite has been revoked",
+ })
+ }
+
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "This invite is not valid",
+ })
+ }
+ } else if (!isInviteUsable(invite)) {
const state = getInviteLifecycleState(invite)
if (state === "revoked") {
throw new TRPCError({
@@ -230,21 +341,35 @@ export const inviteRouter = createTRPCRouter({
const claimedAt = new Date()
- // Claim invite atomically - only succeeds if invite is still usable.
- const claimResult = await tx.invite.updateMany({
- where: {
- id: invite.id,
- status: "PENDING",
- claimedAt: null,
- revokedAt: null,
- expiresAt: { gt: claimedAt },
- },
- data: {
- status: "CLAIMED",
- claimedById: user.id,
- claimedAt,
- },
- })
+ const claimResult = isReusableInvite(invite)
+ ? await tx.invite.updateMany({
+ where: {
+ id: invite.id,
+ isReusable: true,
+ status: "PENDING",
+ revokedAt: null,
+ },
+ data: {
+ useCount: {
+ increment: 1,
+ },
+ lastUsedAt: claimedAt,
+ },
+ })
+ : await tx.invite.updateMany({
+ where: {
+ id: invite.id,
+ status: "PENDING",
+ claimedAt: null,
+ revokedAt: null,
+ expiresAt: { gt: claimedAt },
+ },
+ data: {
+ status: "CLAIMED",
+ claimedById: user.id,
+ claimedAt,
+ },
+ })
if (claimResult.count === 0) {
throw new TRPCError({
@@ -279,8 +404,12 @@ export const inviteRouter = createTRPCRouter({
eventType: "INVITE_STATUS_CHANGED" as const,
sourceType: "invite",
sourceId: invite.id,
- title: "An invite was claimed",
- body: "A pending invite has been claimed.",
+ title: isReusableInvite(invite)
+ ? "A family invite link was used"
+ : "An invite was claimed",
+ body: isReusableInvite(invite)
+ ? "A reusable family invite link was used to join the family."
+ : "A pending invite has been claimed.",
})),
)
}
@@ -352,6 +481,131 @@ export const inviteRouter = createTRPCRouter({
}
}),
+ resetReusableInvite: protectedProcedure
+ .input(resetReusableInviteInputSchema)
+ .mutation(async ({ ctx, input }) => {
+ const membership = await ctx.db.familyMember.findUnique({
+ where: {
+ familyId_userId: {
+ familyId: input.familyId,
+ userId: ctx.session.user.id,
+ },
+ },
+ })
+
+ if (!membership || (membership.role !== "ADMIN" && membership.role !== "OWNER")) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You do not have permission to manage family invite links for this family",
+ })
+ }
+
+ const result = await ctx.db.$transaction(async (tx) => {
+ const activeReusableInvite = await tx.invite.findFirst({
+ where: {
+ familyId: input.familyId,
+ isReusable: true,
+ claimMemberId: null,
+ status: "PENDING",
+ revokedAt: null,
+ },
+ orderBy: { createdAt: "desc" },
+ })
+
+ const resetAt = new Date()
+
+ if (activeReusableInvite) {
+ await tx.invite.updateMany({
+ where: {
+ familyId: input.familyId,
+ isReusable: true,
+ claimMemberId: null,
+ status: "PENDING",
+ revokedAt: null,
+ },
+ data: {
+ status: "REVOKED",
+ revokedAt: resetAt,
+ },
+ })
+ }
+
+ let code: string | undefined
+
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const candidate = generateInviteCode()
+ const conflict = await tx.invite.findUnique({ where: { code: candidate } })
+ if (!conflict) {
+ code = candidate
+ break
+ }
+ }
+
+ if (!code) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to generate unique invite code",
+ })
+ }
+
+ const createdInvite = await tx.invite.create({
+ data: {
+ code,
+ type: "OPEN",
+ invitedEmail: null,
+ familyId: input.familyId,
+ createdById: ctx.session.user.id,
+ expiresAt: REUSABLE_INVITE_FUTURE_EXPIRY,
+ status: "PENDING",
+ isReusable: true,
+ rotatedFromInviteId: activeReusableInvite?.id ?? null,
+ },
+ })
+
+ const adminRecipientIds = await getClaimedAdminMemberIds(tx, input.familyId, [membership.id])
+ if (adminRecipientIds.length > 0) {
+ const notifications = []
+
+ if (activeReusableInvite) {
+ notifications.push(
+ ...adminRecipientIds.map((recipientMemberId) => ({
+ familyId: input.familyId,
+ recipientMemberId,
+ actorMemberId: membership.id,
+ category: "INVITE" as const,
+ eventType: "INVITE_STATUS_CHANGED" as const,
+ sourceType: "invite",
+ sourceId: activeReusableInvite.id,
+ title: "A family invite link was reset",
+ body: "A family admin reset the reusable family invite link.",
+ })),
+ )
+ }
+
+ notifications.push(
+ ...adminRecipientIds.map((recipientMemberId) => ({
+ familyId: input.familyId,
+ recipientMemberId,
+ actorMemberId: membership.id,
+ category: "INVITE" as const,
+ eventType: "INVITE_CREATED" as const,
+ sourceType: "invite",
+ sourceId: createdInvite.id,
+ title: "A new family invite link was created",
+ body: "A family admin created a reusable family invite link.",
+ })),
+ )
+
+ const createdNotifications = await createNotifications(tx, notifications)
+ void dispatchPushForNotifications(createdNotifications)
+ }
+
+ return createdInvite
+ })
+
+ return buildReusableInviteSummary(result)
+ }),
+
/**
* Protected mutation: Create a new invite (admin-only).
* Generates a unique code and sets expiry based on TTL.
@@ -586,6 +840,7 @@ export const inviteRouter = createTRPCRouter({
id: inv.id,
code: inv.code,
type: inv.type,
+ isReusable: inv.isReusable,
isClaimInvite: inv.claimMemberId !== null,
claimMember: inv.claimMember
? {
diff --git a/test/lib/invite-claim.test.ts b/test/lib/invite-claim.test.ts
index 237bcc4..93ae0f3 100644
--- a/test/lib/invite-claim.test.ts
+++ b/test/lib/invite-claim.test.ts
@@ -1,8 +1,11 @@
import { describe, it, expect } from "vitest";
import {
+ isReusableInvite,
isClaimInvite,
isMemberAlreadyClaimed,
getClaimLifecycleState,
+ getReusableInviteLifecycleState,
+ isReusableInviteUsable,
validateClaimEmailBinding,
generateClaimToken,
getInviteExpiryDate,
@@ -38,6 +41,18 @@ describe("isClaimInvite", () => {
});
});
+// ─── isReusableInvite ─────────────────────────────────────────────────────────
+
+describe("isReusableInvite", () => {
+ it("returns true when isReusable is true", () => {
+ expect(isReusableInvite({ isReusable: true })).toBe(true);
+ });
+
+ it("returns false when isReusable is false", () => {
+ expect(isReusableInvite({ isReusable: false })).toBe(false);
+ });
+});
+
// ─── isMemberAlreadyClaimed ────────────────────────────────────────────────────
describe("isMemberAlreadyClaimed", () => {
@@ -102,6 +117,86 @@ describe("getClaimLifecycleState", () => {
});
});
+// ─── getReusableInviteLifecycleState ──────────────────────────────────────────
+
+describe("getReusableInviteLifecycleState", () => {
+ it('returns "invalid" when invite is null', () => {
+ expect(getReusableInviteLifecycleState(null)).toBe("invalid");
+ });
+
+ it('returns "invalid" when invite is not marked reusable', () => {
+ expect(
+ getReusableInviteLifecycleState({
+ isReusable: false,
+ status: "PENDING",
+ revokedAt: null,
+ }),
+ ).toBe("invalid");
+ });
+
+ it('returns "valid" for a pending reusable invite', () => {
+ expect(
+ getReusableInviteLifecycleState({
+ isReusable: true,
+ status: "PENDING",
+ revokedAt: null,
+ }),
+ ).toBe("valid");
+ });
+
+ it('returns "revoked" when revokedAt is set', () => {
+ expect(
+ getReusableInviteLifecycleState({
+ isReusable: true,
+ status: "PENDING",
+ revokedAt: new Date(),
+ }),
+ ).toBe("revoked");
+ });
+
+ it('returns "revoked" when status is REVOKED', () => {
+ expect(
+ getReusableInviteLifecycleState({
+ isReusable: true,
+ status: "REVOKED",
+ revokedAt: null,
+ }),
+ ).toBe("revoked");
+ });
+
+ it('returns "invalid" for non-pending reusable invite states', () => {
+ expect(
+ getReusableInviteLifecycleState({
+ isReusable: true,
+ status: "CLAIMED",
+ revokedAt: null,
+ }),
+ ).toBe("invalid");
+ });
+});
+
+describe("isReusableInviteUsable", () => {
+ it("returns true only for valid reusable invites", () => {
+ expect(
+ isReusableInviteUsable({
+ isReusable: true,
+ status: "PENDING",
+ revokedAt: null,
+ }),
+ ).toBe(true);
+ });
+
+ it("returns false for revoked reusable invites", () => {
+ expect(
+ isReusableInviteUsable({
+ isReusable: true,
+ status: "REVOKED",
+ revokedAt: null,
+ }),
+ ).toBe(false);
+ });
+});
+
// ─── validateClaimEmailBinding ─────────────────────────────────────────────────
describe("validateClaimEmailBinding", () => {
diff --git a/test/lib/invite-schemas.test.ts b/test/lib/invite-schemas.test.ts
index 1ee46ed..3d99ad6 100644
--- a/test/lib/invite-schemas.test.ts
+++ b/test/lib/invite-schemas.test.ts
@@ -3,6 +3,9 @@ import {
createUnclaimedMemberInputSchema,
createClaimLinkInputSchema,
claimMemberInputSchema,
+ getActiveReusableInviteInputSchema,
+ resetReusableInviteInputSchema,
+ reusableInviteSummarySchema,
} from "~/lib/invite-schemas";
import { CLAIM_DEFAULT_TTL_DAYS } from "~/lib/invite";
@@ -203,3 +206,84 @@ describe("claimMemberInputSchema", () => {
expect(result.success).toBe(false);
});
});
+
+// ─── reusable invite schemas ──────────────────────────────────────────────────
+
+describe("getActiveReusableInviteInputSchema", () => {
+ it("accepts a valid familyId", () => {
+ const result = getActiveReusableInviteInputSchema.safeParse({
+ familyId: VALID_CUID,
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it("rejects missing familyId", () => {
+ const result = getActiveReusableInviteInputSchema.safeParse({});
+ expect(result.success).toBe(false);
+ });
+});
+
+describe("resetReusableInviteInputSchema", () => {
+ it("accepts a valid familyId", () => {
+ const result = resetReusableInviteInputSchema.safeParse({
+ familyId: VALID_CUID,
+ });
+ expect(result.success).toBe(true);
+ });
+});
+
+describe("reusableInviteSummarySchema", () => {
+ it("accepts valid reusable invite metadata for UI parsing", () => {
+ const result = reusableInviteSummarySchema.safeParse({
+ id: "invite-1",
+ code: "A".repeat(24),
+ familyId: VALID_CUID,
+ isReusable: true,
+ status: "PENDING",
+ lifecycleState: "valid",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ revokedAt: null,
+ rotatedFromInviteId: null,
+ useCount: 3,
+ lastUsedAt: new Date(),
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it("rejects non-reusable payloads", () => {
+ const result = reusableInviteSummarySchema.safeParse({
+ id: "invite-1",
+ code: "A".repeat(24),
+ familyId: VALID_CUID,
+ isReusable: false,
+ status: "PENDING",
+ lifecycleState: "valid",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ revokedAt: null,
+ rotatedFromInviteId: null,
+ useCount: 0,
+ lastUsedAt: null,
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it("rejects negative useCount", () => {
+ const result = reusableInviteSummarySchema.safeParse({
+ id: "invite-1",
+ code: "A".repeat(24),
+ familyId: VALID_CUID,
+ isReusable: true,
+ status: "PENDING",
+ lifecycleState: "valid",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ revokedAt: null,
+ rotatedFromInviteId: null,
+ useCount: -1,
+ lastUsedAt: null,
+ });
+ expect(result.success).toBe(false);
+ });
+});
diff --git a/test/server/api/routers/invite.test.ts b/test/server/api/routers/invite.test.ts
index 42dfe38..9bbd84a 100644
--- a/test/server/api/routers/invite.test.ts
+++ b/test/server/api/routers/invite.test.ts
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
+/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unnecessary-type-assertion */
+
const mockedEnv = vi.hoisted(() => ({
DATABASE_URL: "postgresql://user:pass@localhost:5432/fircle_test",
SELF_HOSTED: true,
@@ -332,6 +334,492 @@ describe("inviteRouter notification producers", () => {
});
});
+describe("inviteRouter reusable family links", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.clearAllMocks();
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ vi.spyOn(console, "info").mockImplementation(() => undefined);
+ });
+
+ const familyId = "clh0000000000000000007001";
+ const actorMemberId = "clh0000000000000000007002";
+
+ it("returns the active reusable invite for a family", async () => {
+ const reusableInvite = {
+ id: "clh0000000000000000007010",
+ code: "REUSABLE_ACTIVE_CODE",
+ familyId,
+ isReusable: true,
+ status: "PENDING",
+ createdAt: new Date("2030-01-01T00:00:00.000Z"),
+ updatedAt: new Date("2030-01-01T00:00:00.000Z"),
+ revokedAt: null,
+ rotatedFromInviteId: null,
+ useCount: 2,
+ lastUsedAt: new Date("2030-01-02T00:00:00.000Z"),
+ claimMemberId: null,
+ };
+
+ const db = {
+ familyMember: {
+ findUnique: vi.fn().mockResolvedValue({
+ id: actorMemberId,
+ familyId,
+ role: "ADMIN",
+ }),
+ },
+ invite: {
+ findFirst: vi.fn().mockResolvedValue(reusableInvite),
+ },
+ } as never;
+
+ const result = await createCaller(db).getActiveReusableInvite({ familyId });
+
+ expect(result).toMatchObject({
+ id: reusableInvite.id,
+ code: reusableInvite.code,
+ isReusable: true,
+ lifecycleState: "valid",
+ useCount: 2,
+ });
+ });
+
+ it("returns null when no reusable invite exists", async () => {
+ const db = {
+ familyMember: {
+ findUnique: vi.fn().mockResolvedValue({
+ id: actorMemberId,
+ familyId,
+ role: "OWNER",
+ }),
+ },
+ invite: {
+ findFirst: vi.fn().mockResolvedValue(null),
+ },
+ } as never;
+
+ const result = await createCaller(db).getActiveReusableInvite({ familyId });
+ expect(result).toBeNull();
+ });
+
+ it("rejects getActiveReusableInvite for non-admin members", async () => {
+ const db = {
+ familyMember: {
+ findUnique: vi.fn().mockResolvedValue({
+ id: actorMemberId,
+ familyId,
+ role: "MEMBER",
+ }),
+ },
+ invite: {
+ findFirst: vi.fn(),
+ },
+ } as never;
+
+ await expect(createCaller(db).getActiveReusableInvite({ familyId })).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ });
+ });
+
+ it("creates the first reusable link when reset is called with no active reusable invite", async () => {
+ const newReusableInvite = {
+ id: "clh0000000000000000007025",
+ code: "FIRST_REUSABLE_CODE",
+ familyId,
+ isReusable: true,
+ status: "PENDING",
+ revokedAt: null,
+ createdAt: new Date("2030-01-03T00:00:00.000Z"),
+ updatedAt: new Date("2030-01-03T00:00:00.000Z"),
+ rotatedFromInviteId: null,
+ useCount: 0,
+ lastUsedAt: null,
+ };
+
+ const tx = {
+ invite: {
+ findFirst: vi.fn().mockResolvedValue(null),
+ updateMany: vi.fn().mockResolvedValue({ count: 0 }),
+ findUnique: vi.fn().mockResolvedValue(null),
+ create: vi.fn().mockResolvedValue(newReusableInvite),
+ },
+ };
+
+ const db = {
+ familyMember: {
+ findUnique: vi.fn().mockResolvedValue({ id: actorMemberId, familyId, role: "OWNER" }),
+ },
+ $transaction: vi.fn(async (cb: (txArg: typeof tx) => Promise) => cb(tx)),
+ } as never;
+
+ const result = await createCaller(db).resetReusableInvite({ familyId });
+
+ expect(tx.invite.updateMany).not.toHaveBeenCalled();
+ expect(tx.invite.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ familyId,
+ isReusable: true,
+ rotatedFromInviteId: null,
+ type: "OPEN",
+ }),
+ }),
+ );
+ expect(result).toMatchObject({
+ id: newReusableInvite.id,
+ code: newReusableInvite.code,
+ lifecycleState: "valid",
+ });
+ });
+
+ it("rejects resetReusableInvite for non-admin members", async () => {
+ const db = {
+ familyMember: {
+ findUnique: vi.fn().mockResolvedValue({
+ id: actorMemberId,
+ familyId,
+ role: "MEMBER",
+ }),
+ },
+ } as never;
+
+ await expect(createCaller(db).resetReusableInvite({ familyId })).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ });
+ });
+
+ it("resetReusableInvite revokes the previous active link and creates a new reusable link", async () => {
+ const activeReusableInvite = {
+ id: "clh0000000000000000007020",
+ code: "OLD_REUSABLE_CODE",
+ familyId,
+ isReusable: true,
+ status: "PENDING",
+ revokedAt: null,
+ createdAt: new Date("2030-01-01T00:00:00.000Z"),
+ updatedAt: new Date("2030-01-01T00:00:00.000Z"),
+ rotatedFromInviteId: null,
+ useCount: 4,
+ lastUsedAt: null,
+ claimMemberId: null,
+ };
+ const newReusableInvite = {
+ id: "clh0000000000000000007021",
+ code: "NEW_REUSABLE_CODE",
+ familyId,
+ isReusable: true,
+ status: "PENDING",
+ revokedAt: null,
+ createdAt: new Date("2030-01-02T00:00:00.000Z"),
+ updatedAt: new Date("2030-01-02T00:00:00.000Z"),
+ rotatedFromInviteId: activeReusableInvite.id,
+ useCount: 0,
+ lastUsedAt: null,
+ };
+
+ const tx = {
+ invite: {
+ findFirst: vi.fn().mockResolvedValue(activeReusableInvite),
+ updateMany: vi.fn().mockResolvedValue({ count: 1 }),
+ findUnique: vi.fn().mockResolvedValue(null),
+ create: vi.fn().mockResolvedValue(newReusableInvite),
+ },
+ };
+
+ const db = {
+ familyMember: {
+ findUnique: vi.fn().mockResolvedValue({ id: actorMemberId, familyId, role: "ADMIN" }),
+ },
+ $transaction: vi.fn(async (cb: (txArg: typeof tx) => Promise) => cb(tx)),
+ } as never;
+
+ const result = await createCaller(db).resetReusableInvite({ familyId });
+
+ expect(tx.invite.updateMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({ familyId, isReusable: true, status: "PENDING" }),
+ data: expect.objectContaining({ status: "REVOKED" }),
+ }),
+ );
+ expect(tx.invite.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ familyId,
+ isReusable: true,
+ type: "OPEN",
+ rotatedFromInviteId: activeReusableInvite.id,
+ }),
+ }),
+ );
+ expect(result).toMatchObject({
+ id: newReusableInvite.id,
+ code: newReusableInvite.code,
+ isReusable: true,
+ lifecycleState: "valid",
+ });
+ });
+
+ it("acceptInvite allows reusable invites even when expiresAt is in the past and increments usage", async () => {
+ const reusableInvite = {
+ id: "clh0000000000000000007030",
+ code: "REUSABLE_JOIN_CODE",
+ type: "OPEN",
+ status: "PENDING",
+ familyId,
+ invitedEmail: null,
+ createdById: "creator-1",
+ expiresAt: new Date("2020-01-01T00:00:00.000Z"),
+ claimedAt: null,
+ claimedById: null,
+ claimMemberId: null,
+ revokedAt: null,
+ isReusable: true,
+ useCount: 0,
+ lastUsedAt: null,
+ };
+ const tx = {
+ user: {
+ create: vi.fn().mockResolvedValue({ id: "user-new", email: "new@example.com" }),
+ },
+ invite: {
+ updateMany: vi.fn().mockResolvedValue({ count: 1 }),
+ },
+ familyMember: {
+ findFirst: vi.fn().mockResolvedValue(null),
+ create: vi.fn().mockResolvedValue({ id: "member-new" }),
+ },
+ };
+ const db = {
+ invite: {
+ findUnique: vi.fn().mockResolvedValue(reusableInvite),
+ },
+ user: {
+ findFirst: vi.fn().mockResolvedValue(null),
+ },
+ $transaction: vi.fn(async (cb: (txArg: typeof tx) => Promise) => cb(tx)),
+ } as never;
+
+ const result = await inviteRouter.createCaller({
+ db,
+ session: null,
+ headers: new Headers(),
+ } as never).acceptInvite({
+ code: reusableInvite.code,
+ email: "new@example.com",
+ password: "password123",
+ name: "New User",
+ });
+
+ expect(result).toMatchObject({ userId: "user-new", email: "new@example.com" });
+ expect(tx.invite.updateMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({ id: reusableInvite.id, isReusable: true, status: "PENDING" }),
+ data: expect.objectContaining({
+ useCount: { increment: 1 },
+ }),
+ }),
+ );
+ });
+
+ it("allows multiple unique users to accept the same reusable code", async () => {
+ const reusableInvite = {
+ id: "clh0000000000000000007035",
+ code: "REUSABLE_MULTI_CODE",
+ type: "OPEN",
+ status: "PENDING",
+ familyId,
+ invitedEmail: null,
+ createdById: "creator-1",
+ expiresAt: new Date("2020-01-01T00:00:00.000Z"),
+ claimedAt: null,
+ claimedById: null,
+ claimMemberId: null,
+ revokedAt: null,
+ isReusable: true,
+ useCount: 0,
+ lastUsedAt: null,
+ };
+
+ let userCounter = 0;
+ const tx = {
+ user: {
+ create: vi.fn().mockImplementation(async (data: { data: { email: string } }) => {
+ userCounter += 1;
+ return { id: `user-${userCounter}`, email: data.data.email };
+ }),
+ },
+ invite: {
+ updateMany: vi.fn().mockResolvedValue({ count: 1 }),
+ },
+ familyMember: {
+ findFirst: vi.fn().mockResolvedValue(null),
+ create: vi.fn().mockResolvedValue({ id: "member-new" }),
+ },
+ };
+
+ const db = {
+ invite: {
+ findUnique: vi.fn().mockResolvedValue(reusableInvite),
+ },
+ user: {
+ findFirst: vi.fn().mockResolvedValue(null),
+ },
+ $transaction: vi.fn(async (cb: (txArg: typeof tx) => Promise) => cb(tx)),
+ } as never;
+
+ const publicCaller = inviteRouter.createCaller({
+ db,
+ session: null,
+ headers: new Headers(),
+ } as never);
+
+ await expect(
+ publicCaller.acceptInvite({
+ code: reusableInvite.code,
+ email: "first@example.com",
+ password: "password123",
+ name: "First User",
+ }),
+ ).resolves.toMatchObject({ email: "first@example.com" });
+
+ await expect(
+ publicCaller.acceptInvite({
+ code: reusableInvite.code,
+ email: "second@example.com",
+ password: "password123",
+ name: "Second User",
+ }),
+ ).resolves.toMatchObject({ email: "second@example.com" });
+
+ expect(tx.invite.updateMany).toHaveBeenCalledTimes(2);
+ expect(tx.invite.updateMany).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ where: expect.objectContaining({ id: reusableInvite.id, isReusable: true, status: "PENDING" }),
+ }),
+ );
+ expect(tx.invite.updateMany).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ where: expect.objectContaining({ id: reusableInvite.id, isReusable: true, status: "PENDING" }),
+ }),
+ );
+ });
+
+ it("rejects single-use invite on second acceptance", async () => {
+ const singleUseInvite = {
+ id: "clh0000000000000000007036",
+ code: "SINGLE_USE_ONCE_16",
+ type: "OPEN",
+ status: "PENDING",
+ familyId,
+ invitedEmail: null,
+ createdById: "creator-1",
+ expiresAt: new Date("2099-01-01T00:00:00.000Z"),
+ claimedAt: null,
+ claimedById: null,
+ claimMemberId: null,
+ revokedAt: null,
+ isReusable: false,
+ useCount: 0,
+ lastUsedAt: null,
+ };
+
+ const tx = {
+ user: {
+ create: vi.fn().mockResolvedValue({ id: "user-1", email: "single@example.com" }),
+ },
+ invite: {
+ updateMany: vi
+ .fn()
+ .mockResolvedValueOnce({ count: 1 })
+ .mockResolvedValueOnce({ count: 0 }),
+ },
+ familyMember: {
+ findFirst: vi.fn().mockResolvedValue(null),
+ create: vi.fn().mockResolvedValue({ id: "member-single" }),
+ },
+ };
+
+ const db = {
+ invite: {
+ findUnique: vi.fn().mockResolvedValue(singleUseInvite),
+ },
+ user: {
+ findFirst: vi.fn().mockResolvedValue(null),
+ },
+ $transaction: vi.fn(async (cb: (txArg: typeof tx) => Promise) => cb(tx)),
+ } as never;
+
+ const publicCaller = inviteRouter.createCaller({
+ db,
+ session: null,
+ headers: new Headers(),
+ } as never);
+
+ await expect(
+ publicCaller.acceptInvite({
+ code: singleUseInvite.code,
+ email: "first.single@example.com",
+ password: "password123",
+ name: "First Single",
+ }),
+ ).resolves.toMatchObject({ email: "single@example.com" });
+
+ await expect(
+ publicCaller.acceptInvite({
+ code: singleUseInvite.code,
+ email: "second.single@example.com",
+ password: "password123",
+ name: "Second Single",
+ }),
+ ).rejects.toMatchObject({ code: "CONFLICT" });
+ });
+
+ it("getByCode accepts reusable invites without treating past expiresAt as invalid", async () => {
+ const reusableInvite = {
+ id: "clh0000000000000000007040",
+ code: "REUSABLE_PUBLIC_CODE",
+ familyId,
+ type: "OPEN",
+ invitedEmail: null,
+ expiresAt: new Date("2020-01-01T00:00:00.000Z"),
+ status: "PENDING",
+ claimedAt: null,
+ revokedAt: null,
+ isReusable: true,
+ family: {
+ id: familyId,
+ name: "Ng",
+ description: "Family description",
+ },
+ };
+
+ const db = {
+ invite: {
+ findUnique: vi.fn().mockResolvedValue(reusableInvite),
+ },
+ } as never;
+
+ const result = await inviteRouter.createCaller({
+ db,
+ session: null,
+ headers: new Headers(),
+ } as never).getByCode({ code: reusableInvite.code });
+
+ expect(result).toMatchObject({
+ id: reusableInvite.id,
+ code: reusableInvite.code,
+ isReusable: true,
+ family: {
+ id: familyId,
+ },
+ });
+ });
+});
+
describe("inviteRouter.createInvite email delivery status", () => {
beforeEach(() => {
vi.restoreAllMocks();