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
|
||||
|
||||
1
.trellis/tasks/07-03-invite-link-limits/check.jsonl
Normal file
1
.trellis/tasks/07-03-invite-link-limits/check.jsonl
Normal file
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
82
.trellis/tasks/07-03-invite-link-limits/design.md
Normal file
82
.trellis/tasks/07-03-invite-link-limits/design.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Invite Link Limits Design
|
||||
|
||||
## Architecture
|
||||
|
||||
This change spans the persisted invitation model, the invitation create API, the registration consumption transaction, and the organization settings UI.
|
||||
|
||||
The invitation row remains the source of truth. The existing `expires_at` column continues to represent validity. New persisted counters will represent usage limits:
|
||||
|
||||
- `max_uses`: maximum successful invitation registrations allowed.
|
||||
- `used_count`: number of successful registrations that have consumed the invitation.
|
||||
|
||||
## Data Model
|
||||
|
||||
Add non-null integer columns to `organization_invitations`:
|
||||
|
||||
- `max_uses integer not null default 1`
|
||||
- `used_count integer not null default 0`
|
||||
|
||||
Existing invitation rows remain compatible: previous one-time invites become `max_uses = 1`, `used_count = 0` unless already accepted. The existing `status = accepted` already prevents use of accepted legacy rows.
|
||||
|
||||
## Invitation Creation Contract
|
||||
|
||||
`POST /api/organizations/[id]/invitations` accepts:
|
||||
|
||||
- `email?: string`
|
||||
- `roleId: string`
|
||||
- `validityDays?: number`
|
||||
- `maxUses?: number`
|
||||
|
||||
Defaults:
|
||||
|
||||
- `validityDays = 7`
|
||||
- `maxUses = 1`
|
||||
|
||||
Validation:
|
||||
|
||||
- `roleId` is still required.
|
||||
- `validityDays` must be a positive integer in an operationally reasonable range.
|
||||
- `maxUses` must be a positive integer in an operationally reasonable range.
|
||||
- Invalid input returns `jsonFailure(...)` with the existing structured API shape.
|
||||
|
||||
## Invitation Consumption
|
||||
|
||||
Registration only counts a use after account creation and membership insertion succeed inside the existing transaction.
|
||||
|
||||
An invitation is valid only if:
|
||||
|
||||
- `status === "active"`
|
||||
- `expiresAt >= now`
|
||||
- `usedCount < maxUses`
|
||||
|
||||
On successful invitation registration:
|
||||
|
||||
- Increment `usedCount`.
|
||||
- If the increment reaches `maxUses`, set `status = "accepted"` and fill `acceptedByAccountId` / `acceptedAt` with the consuming account details.
|
||||
- If remaining uses exist, keep `status = "active"` and leave accepted metadata empty.
|
||||
|
||||
The update should guard against concurrent over-consumption by updating with a `used_count < max_uses` predicate and checking that a row was returned.
|
||||
|
||||
## UI
|
||||
|
||||
`OrganizationInviteDialog` adds controls for:
|
||||
|
||||
- Validity days, default 7.
|
||||
- Maximum uses, default 1.
|
||||
|
||||
`OrganizationDetailClient` displays:
|
||||
|
||||
- Expiry time.
|
||||
- Usage as `usedCount / maxUses`.
|
||||
|
||||
Copy behavior remains unchanged.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The existing `/register?invite=...` URL format remains unchanged.
|
||||
|
||||
Existing rows without the new columns are migrated through default values. Existing one-time semantics are preserved by default.
|
||||
|
||||
## Rollback
|
||||
|
||||
If needed, UI inputs can be hidden and backend defaults can keep the old behavior. Dropping the columns would require a reverse migration only if deployed migrations must be rolled back.
|
||||
1
.trellis/tasks/07-03-invite-link-limits/implement.jsonl
Normal file
1
.trellis/tasks/07-03-invite-link-limits/implement.jsonl
Normal file
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
42
.trellis/tasks/07-03-invite-link-limits/implement.md
Normal file
42
.trellis/tasks/07-03-invite-link-limits/implement.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Invite Link Limits Implementation Plan
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Update Trellis planning artifacts and get approval to start implementation.
|
||||
2. Load pre-development specs with `trellis-before-dev`.
|
||||
3. Update `modules/core/server/schema.ts`:
|
||||
- Add `maxUses` and `usedCount` integer columns to `organizationInvitations`.
|
||||
4. Generate or add the Drizzle migration for the new columns.
|
||||
5. Update shared invitation types and row mappers:
|
||||
- `modules/core/types.ts`
|
||||
- `modules/core/server/settings.ts`
|
||||
- `modules/core/server/store.ts`
|
||||
6. Update invitation creation API:
|
||||
- Parse `validityDays` and `maxUses`.
|
||||
- Validate positive integer ranges.
|
||||
- Persist `expiresAt`, `maxUses`, and `usedCount`.
|
||||
7. Update invitation registration flow:
|
||||
- Reject exhausted links.
|
||||
- Increment usage inside the transaction.
|
||||
- Mark status accepted only when `usedCount` reaches `maxUses`.
|
||||
- Guard concurrent consumption with an update predicate.
|
||||
8. Update frontend:
|
||||
- Add validity and maximum-use controls to `OrganizationInviteDialog`.
|
||||
- Display usage count in `OrganizationDetailClient`.
|
||||
- Update invitation explanatory text where it still says invitations are one-time only.
|
||||
9. Run validation:
|
||||
- `pnpm lint`
|
||||
- `pnpm type-check`
|
||||
- `pnpm test` if tests are relevant or fast enough after type changes.
|
||||
- `pnpm build` if lint/type-check pass.
|
||||
|
||||
## Risk Points
|
||||
|
||||
- Concurrent registrations could over-consume an invite unless the update predicate checks `used_count < max_uses`.
|
||||
- Existing accepted invitations should remain unusable regardless of migrated counter defaults.
|
||||
- Frontend numeric inputs submit strings, so backend parsing must not trust the client.
|
||||
|
||||
## Rollback Points
|
||||
|
||||
- Before migration: schema/API/UI changes can be reverted together.
|
||||
- After migration: reverting code while keeping columns is safe because defaults preserve one-use invite behavior.
|
||||
48
.trellis/tasks/07-03-invite-link-limits/prd.md
Normal file
48
.trellis/tasks/07-03-invite-link-limits/prd.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Invite link limits
|
||||
|
||||
## Goal
|
||||
|
||||
Allow organization admins to configure invitation link validity and usage limits when creating invite links.
|
||||
|
||||
This should make invite links safer to distribute by letting admins choose how long a link remains valid and how many times it may be used.
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- Invitation links are created from `modules/settings/components/OrganizationInviteDialog.tsx`.
|
||||
- The invitation create API is `app/api/organizations/[id]/invitations/route.ts`.
|
||||
- Invitation persistence is backed by the `organization_invitations` table in `modules/core/server/schema.ts`.
|
||||
- Invitations already have an `expiresAt` column and are currently created with a fixed 7-day validity window.
|
||||
- Registration consumes invitation tokens through `modules/core/server/auth.ts`.
|
||||
- The current registration flow marks an invitation as `accepted` after one successful invitation registration, so existing invite links behave as single-use links.
|
||||
- Invitation rows are shown in `modules/settings/components/OrganizationDetailClient.tsx`, currently with target, role, status, expiry, and copy action.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Admins can configure an invitation link's validity period when creating an organization invitation.
|
||||
- Admins can configure the maximum usage count for an invitation link when creating an organization invitation.
|
||||
- Existing behavior remains compatible by default: if the admin does not change the controls, a new invite should expire after 7 days and be usable once.
|
||||
- The backend validates create-invitation inputs and rejects invalid limit values with structured API failures.
|
||||
- The registration path rejects expired or exhausted invitation links.
|
||||
- Invitation list data exposes enough information to show configured limits and current usage.
|
||||
- The UI makes the configured validity and usage limit visible when creating and reviewing invitations.
|
||||
- Maximum access count means maximum successful invitation registrations, not page loads or token preview requests.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Invitation revocation management beyond existing status behavior.
|
||||
- Editing limits after an invitation has been created.
|
||||
- Tracking anonymous page views unless product intent explicitly requires page-load based access counting.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Creating an invitation with default form values produces a 7-day, one-use invitation.
|
||||
- [x] Creating an invitation with a custom validity period stores and displays the corresponding expiry.
|
||||
- [x] Creating an invitation with a custom maximum usage count stores and displays the configured usage limit.
|
||||
- [x] Registration with an expired invitation fails with the existing invalid/expired invitation behavior.
|
||||
- [x] Registration after the invitation has reached its maximum allowed uses fails.
|
||||
- [x] Successful invitation registration increments usage accounting and only exhausts the link when the configured maximum is reached.
|
||||
- [x] Type-check, lint, and relevant tests/build checks pass.
|
||||
|
||||
## Notes
|
||||
|
||||
- Recommended interpretation: count successful invitation registrations as usage. Counting page loads would make links vulnerable to browser refreshes, previews, bots, and accidental visits, and it would require adding a separate token validation/view endpoint.
|
||||
26
.trellis/tasks/07-03-invite-link-limits/task.json
Normal file
26
.trellis/tasks/07-03-invite-link-limits/task.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "invite-link-limits",
|
||||
"name": "invite-link-limits",
|
||||
"title": "Invite link limits",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "TalexDreamSoul",
|
||||
"assignee": "TalexDreamSoul",
|
||||
"createdAt": "2026-07-03",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
Reference in New Issue
Block a user