feat: add invitation link limits

This commit is contained in:
2026-07-03 04:59:34 -07:00
parent fb15d4db47
commit 9fd2088a2a
20 changed files with 4968 additions and 15 deletions

View File

@@ -2,6 +2,14 @@ import { randomBytes } from "node:crypto";
import { and, eq } from "drizzle-orm";
import {
DEFAULT_INVITATION_MAX_USES,
DEFAULT_INVITATION_VALIDITY_DAYS,
MAX_INVITATION_MAX_USES,
MAX_INVITATION_VALIDITY_DAYS,
MIN_INVITATION_MAX_USES,
MIN_INVITATION_VALIDITY_DAYS,
} from "@/modules/core/invitation-limits";
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
@@ -23,6 +31,25 @@ function readString(source: Record<string, unknown>, key: string): string {
return typeof value === "string" ? value.trim() : "";
}
function readBoundedInteger(
source: Record<string, unknown>,
key: string,
options: {
defaultValue: number;
min: number;
max: number;
label: string;
},
): { success: true; value: number } | { success: false; reason: string } {
const rawValue = source[key];
const value = rawValue === undefined || rawValue === null ? options.defaultValue : Number(rawValue);
if (!Number.isInteger(value) || value < options.min || value > options.max) {
return { success: false, reason: `${options.label}需为 ${options.min}-${options.max} 的整数` };
}
return { success: true, value };
}
function createInvitationToken(): string {
return randomBytes(24).toString("base64url");
}
@@ -55,6 +82,26 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
return jsonFailure("请选择邀请角色");
}
const validityDaysResult = readBoundedInteger(body, "validityDays", {
defaultValue: DEFAULT_INVITATION_VALIDITY_DAYS,
min: MIN_INVITATION_VALIDITY_DAYS,
max: MAX_INVITATION_VALIDITY_DAYS,
label: "邀请有效期",
});
if (!validityDaysResult.success) {
return jsonFailure(validityDaysResult.reason);
}
const maxUsesResult = readBoundedInteger(body, "maxUses", {
defaultValue: DEFAULT_INVITATION_MAX_USES,
min: MIN_INVITATION_MAX_USES,
max: MAX_INVITATION_MAX_USES,
label: "最大使用次数",
});
if (!maxUsesResult.success) {
return jsonFailure(maxUsesResult.reason);
}
const database = getDatabase();
const organizationRows = await database.select().from(organizations).where(eq(organizations.id, id)).limit(1);
const organization = organizationRows[0];
@@ -81,7 +128,9 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
token: createInvitationToken(),
status: "active",
createdByAccountId: auth.context.account.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
expiresAt: new Date(Date.now() + validityDaysResult.value * 24 * 60 * 60 * 1000),
maxUses: maxUsesResult.value,
usedCount: 0,
})
.returning();
const invitation = rows[0];
@@ -96,7 +145,7 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
targetType: "organizationInvitation",
targetId: invitation.id,
result: "success",
reason: `创建机构邀请:${email || "通用邀请"}`,
reason: `创建机构邀请:${email || "通用邀请"},有效期 ${validityDaysResult.value} 天,最多使用 ${maxUsesResult.value}`,
});
return jsonSuccess("邀请已创建", { invitation }, 201);