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

@@ -111,6 +111,107 @@ await database.transaction(async (transaction) => {
});
```
## Scenario: Organization Invitation Limits
### 1. Scope / Trigger
- Trigger: changing organization invitation creation, registration-by-invite consumption, or the `organization_invitations` table.
- Applies because invitation links are persisted in PostgreSQL, created through a protected organization API, consumed inside account registration, and displayed in organization settings.
### 2. Signatures
- `POST /api/organizations/[id]/invitations`
- Request: `{ email?: string; roleId: string; validityDays?: number; maxUses?: number }`
- Response: `ApiResult<{ invitation: typeof organizationInvitations.$inferSelect }>`
- DB columns:
- `organization_invitations.expires_at timestamp with time zone not null`
- `organization_invitations.max_uses integer not null default 1`
- `organization_invitations.used_count integer not null default 0`
- Registration helper: `createRegistration({ name, email, password, organizationId?, invitationToken? })`
### 3. Contracts
- Invite creation requires `account:manage` and may only target the active organization when the session is organization-scoped.
- `roleId` must refer to an enabled role in the target organization.
- If invitation `email` is non-empty, registration must use the same normalized email address.
- `validityDays` defaults to `7` and must be a positive integer within the product limit.
- `maxUses` defaults to `1` and must be a positive integer within the product limit.
- "Use count" means successful invitation registrations, not page visits or token preview requests.
- A token is consumable only when `status = 'active'`, `expires_at >= now`, and `used_count < max_uses`.
- Successful registration inserts the account and membership in the same transaction before consuming the invitation.
- Consuming an invitation increments `used_count`; only when the returned count reaches `max_uses` should the row become `status = 'accepted'` and receive `accepted_by_account_id` / `accepted_at`.
### 4. Validation & Error Matrix
- Invalid JSON body -> `400` / `请求数据格式无效`.
- Missing `roleId` -> `400` / `请选择邀请角色`.
- Invalid `validityDays` -> `400` / `邀请有效期需为 ... 的整数`.
- Invalid `maxUses` -> `400` / `最大使用次数需为 ... 的整数`.
- Target organization missing -> `404` / `机构不存在`.
- Role missing, disabled, or cross-organization -> `404` / `角色不存在`.
- Unknown invitation token during registration -> `邀请链接无效`.
- Expired, non-active, or exhausted invitation token during registration -> `邀请链接已失效`.
- Registration email mismatch for an email-limited invitation -> `邀请邮箱与注册邮箱不一致`.
### 5. Good/Base/Bad Cases
- Good: keep invitation limit constants shared between the create API and invite dialog so UI bounds and backend validation stay aligned.
- Good: consume invitation links with an atomic `UPDATE ... WHERE used_count < max_uses` predicate and check that a row was returned.
- Good: derive invitation list UI from the persisted `used_count / max_uses` fields.
- Base: old one-use behavior remains the default by using `validityDays = 7` and `maxUses = 1`.
- Bad: count page loads as invitation usage; link previews, refreshes, and bots can exhaust a link without a registration.
- Bad: decide whether a link is exhausted only from a value read before registration; concurrent registrations can over-consume the link.
### 6. Tests Required
- `pnpm db:generate`, then review SQL for only additive invitation limit columns or intentional invitation changes.
- `pnpm lint`
- `pnpm type-check`
- `pnpm test`
- `pnpm build`
- Integration assertions when route-level tests cover auth registration:
- default invite creation returns a 7-day, one-use link
- custom `validityDays` and `maxUses` are persisted
- expired and exhausted links fail registration
- successful registration increments `used_count`
- the final allowed registration marks the invitation accepted
### 7. Wrong vs Correct
#### Wrong
```ts
if (invitation.usedCount >= invitation.maxUses) {
throw new Error("邀请链接已失效");
}
await transaction.update(organizationInvitations)
.set({ usedCount: invitation.usedCount + 1 })
.where(eq(organizationInvitations.id, invitation.id));
```
#### Correct
```ts
const consumedRows = await transaction
.update(organizationInvitations)
.set({ usedCount: sql`${organizationInvitations.usedCount} + 1` })
.where(and(
eq(organizationInvitations.id, invitation.id),
eq(organizationInvitations.status, "active"),
gte(organizationInvitations.expiresAt, now),
lt(organizationInvitations.usedCount, organizationInvitations.maxUses),
))
.returning({
id: organizationInvitations.id,
maxUses: organizationInvitations.maxUses,
usedCount: organizationInvitations.usedCount,
});
const consumed = consumedRows[0];
if (!consumed) {
throw new Error("邀请链接已失效");
}
```
## Scenario: Facility Room Creation Without Fabricated Hierarchy
### 1. Scope / Trigger