feat: add invitation link limits
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user