From 24f7310931c298ad50f823f5a781925938822be8 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 12 Sep 2026 14:34:14 -0500 Subject: [PATCH 1/5] Tell inviters what happened to the email and cap invitation sends The invite modal always said "Invitation sent", even when nothing was mailed. addProjectMember now returns a delivery state (queued, recently_sent, not_sent) and the toast reflects it. Postmark codes 300 and 406 are permanent: the queue consumer acks those instead of retrying three times, and both it and the dead-letter handler mark the invitation row emailStatus = 'undeliverable'. The pending invitations list shows that row as "Email could not be delivered". Invitation creation had no server-side cap, so one project owner could turn the invite form into a bulk sender through our Postmark account. createInvitation now enforces INVITATION_LIMITS: 20 live invitations per project, 30 created per inviter per hour, one email per address per ten minutes. The collaborator plan quota is also checked at create time, counting live invitations to people not yet in the workspace as seats. project_invitations gains a unique index on (projectId, email); the insert falls back to the resend path when it loses a double-submit race. Email matching on the invitee side uses normalizeEmail everywhere. Claude-Session: https://claude.ai/code/session_014whY28tH1iAuRFLhWA4c5p --- packages/db/src/schema.ts | 11 +- packages/docs/guides/database.md | 51 +- packages/docs/guides/organizations.md | 20 +- packages/shared/src/email.ts | 9 + packages/shared/src/errors/domains/domain.ts | 11 + .../0012_invitation_email_status.sql | 3 + .../web/migrations/meta/0012_snapshot.json | 2119 +++++++++++++++++ packages/web/migrations/meta/_journal.json | 9 +- .../web/src/__tests__/server/migration-sql.js | 6 +- .../project/overview-tab/AddMemberModal.tsx | 24 +- .../overview-tab/PendingInvitations.tsx | 6 +- packages/web/src/lib/error-utils.ts | 4 + packages/web/src/server.ts | 5 +- .../__tests__/invitations.server.test.ts | 19 +- .../org-projects-members.server.test.ts | 119 + .../server/functions/invitations.server.ts | 5 +- .../server/functions/org-projects.server.ts | 62 +- packages/workers/queue.d.ts | 2 +- packages/workers/src/auth/email.ts | 12 +- .../commands/invitations/createInvitation.ts | 173 +- packages/workers/src/config/constants.ts | 8 + .../src/lib/__tests__/email-queue.test.ts | 73 +- .../workers/src/lib/send-invitation-email.ts | 4 +- packages/workers/src/queue.ts | 38 +- 24 files changed, 2665 insertions(+), 128 deletions(-) create mode 100644 packages/web/migrations/0012_invitation_email_status.sql create mode 100644 packages/web/migrations/meta/0012_snapshot.json diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index d9b0d94d3..4d510d9f2 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -381,9 +381,18 @@ export const projectInvitations = sqliteTable( .references(() => user.id, { onDelete: 'cascade' }), expiresAt: integer('expiresAt', { mode: 'timestamp' }).notNull(), acceptedAt: integer('acceptedAt', { mode: 'timestamp' }), + // Last time an invitation email was queued; null when no email has gone out + emailSentAt: integer('emailSentAt', { mode: 'timestamp' }), + // 'queued' once handed to the email queue, 'undeliverable' when the + // provider rejected the address for good or retries ran out + emailStatus: text('emailStatus').$type<'queued' | 'undeliverable'>(), createdAt: integer('createdAt', { mode: 'timestamp' }).default(sql`(unixepoch())`), }, - t => [index('project_invitations_projectId_idx').on(t.projectId)], + t => [ + index('project_invitations_projectId_idx').on(t.projectId), + // Create-or-resend is read-then-write; this closes the double-submit gap + uniqueIndex('project_invitations_projectId_email_uidx').on(t.projectId, t.email), + ], ); // Public contact form submissions. The row is written before the notification diff --git a/packages/docs/guides/database.md b/packages/docs/guides/database.md index 3ebf0e197..021d20afc 100644 --- a/packages/docs/guides/database.md +++ b/packages/docs/guides/database.md @@ -132,28 +132,39 @@ export const projectMembers = sqliteTable('project_members', { Project invitations include optional org membership granting: ```js -export const projectInvitations = sqliteTable('project_invitations', { - id: text('id').primaryKey(), - orgId: text('orgId') - .notNull() - .references(() => organization.id, { onDelete: 'cascade' }), - projectId: text('projectId') - .notNull() - .references(() => projects.id, { onDelete: 'cascade' }), - email: text('email').notNull(), - role: text('role').default('member'), // project role to assign - orgRole: text('orgRole').default('member'), // org role if grantOrgMembership is true - grantOrgMembership: integer('grantOrgMembership', { mode: 'boolean' }).default(false).notNull(), - token: text('token').notNull().unique(), - invitedBy: text('invitedBy') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - expiresAt: integer('expiresAt', { mode: 'timestamp' }).notNull(), - acceptedAt: integer('acceptedAt', { mode: 'timestamp' }), - createdAt: integer('createdAt', { mode: 'timestamp' }).default(sql`(unixepoch())`), -}); +export const projectInvitations = sqliteTable( + 'project_invitations', + { + id: text('id').primaryKey(), + orgId: text('orgId') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + projectId: text('projectId') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + email: text('email').notNull(), + role: text('role').default('member'), // project role to assign + orgRole: text('orgRole').default('member'), // org role if grantOrgMembership is true + grantOrgMembership: integer('grantOrgMembership', { mode: 'boolean' }).default(false).notNull(), + token: text('token').notNull().unique(), + invitedBy: text('invitedBy') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + expiresAt: integer('expiresAt', { mode: 'timestamp' }).notNull(), + acceptedAt: integer('acceptedAt', { mode: 'timestamp' }), + emailSentAt: integer('emailSentAt', { mode: 'timestamp' }), // last time an email was queued + emailStatus: text('emailStatus'), // 'queued' | 'undeliverable' | null + createdAt: integer('createdAt', { mode: 'timestamp' }).default(sql`(unixepoch())`), + }, + t => [ + index('project_invitations_projectId_idx').on(t.projectId), + uniqueIndex('project_invitations_projectId_email_uidx').on(t.projectId, t.email), + ], +); ``` +One row per project and address: re-inviting the same email updates the existing row (role, expiry, token if it had been accepted) rather than adding another. `emailStatus` becomes `undeliverable` when Postmark rejects the address for good (suppressed or malformed) or the queue exhausts its retries; the pending-invitations list shows that to the inviter. + **Note:** Projects are always invite-only. By default, accepting an invitation grants project membership only. The `grantOrgMembership` field can be set to `true` by org admins/owners to also grant organization membership (for governance/billing purposes). #### Subscriptions (Better Auth Stripe Plugin) diff --git a/packages/docs/guides/organizations.md b/packages/docs/guides/organizations.md index 655477b20..8b0f45c9d 100644 --- a/packages/docs/guides/organizations.md +++ b/packages/docs/guides/organizations.md @@ -32,7 +32,7 @@ Schema lives in `packages/db/src/schema.ts` -- the canonical reference. Relevant - `organization`, `member`, `invitation` -- Better Auth organization plugin. `member.role`: `owner | admin | member`. - `projects` -- `id`, `name`, `description`, `orgId` (FK, cascade), `createdBy`. - `projectMembers` -- `projectId` (FK), `userId` (FK), `role` (`owner | member`), `joinedAt`. -- `projectInvitations` -- `orgId`, `projectId`, `email`, `role`, `orgRole`, `grantOrgMembership`, `token` (unique), `expiresAt`, `acceptedAt`. +- `projectInvitations` -- `orgId`, `projectId`, `email`, `role`, `orgRole`, `grantOrgMembership`, `token` (unique), `expiresAt`, `acceptedAt`, `emailSentAt`, `emailStatus`. Unique on (`projectId`, `email`). The `grantOrgMembership` flag on an invitation says "also add this user to the org at `orgRole` when they accept." Defaults to `false`; only org admins/owners can set it to `true`. @@ -193,14 +193,16 @@ Backed by Better Auth's `authClient.organization.list()` plus auth-aware `enable Every project add is an invitation: whether the owner picks an existing user or types an unknown email, the server creates a `projectInvitations` row and emails a link, and membership is only created when the recipient accepts. Direct membership writes are reserved for internal/test tooling (`addMember` command, dev routes). -1. Project owner calls `POST /api/orgs/:orgId/projects/:projectId/invitations` with `{ email, role, grantOrgMembership?, orgRole? }`. -2. Server creates a `projectInvitations` row with a unique token and sends a magic link. -3. Invitee clicks the link, lands on `/complete-profile?invitation=TOKEN`, completes profile if needed. -4. Frontend calls `POST /api/invitations/accept` with the token. -5. Server validates: token exists, not expired, not accepted. The invited email is a delivery address, not an identity check: membership binds to whichever authenticated account accepts the token, so someone invited at an institutional alias can accept from an account keyed to a different address. -6. If `grantOrgMembership === true`, the server adds org membership with `orgRole` (if the user isn't already a member). -7. Server adds `projectMembers` with `role`. -8. Frontend redirects to the project. +1. Project owner calls `addMemberToProject` with `{ userId | email, role }`. +2. Server checks the collaborator quota (non-owner members plus live invitations to people not yet in the workspace count as seats; inviting an existing workspace member takes none), then `createInvitation` applies the send caps from `INVITATION_LIMITS`: at most 20 live invitations per project, at most 30 created per inviter per hour, and one email per address per 10 minutes. +3. Server creates or updates the `projectInvitations` row (one per project and address) and queues an email carrying `/invite/`. The result reports `delivery` as `queued`, `recently_sent` (inside the cooldown, no new email) or `not_sent` (queue failure or an address that cannot take mail); the invite modal's toast reflects it. An account matching the address also gets an `invitation.received` notification. +4. The queue consumer acks a permanently rejected address (Postmark 300/406) without retrying and marks the row `emailStatus = 'undeliverable'`; a dead-lettered message does the same. The pending-invitations list shows "Email could not be delivered" for that row. +5. Invitee opens the link, lands on `/invite/$token`, and signs up or signs in if needed. +6. Frontend calls `acceptInvitation` with the token. +7. Server validates: token exists, not expired, not accepted. The invited email is a delivery address, not an identity check: membership binds to whichever authenticated account accepts the token, so someone invited at an institutional alias can accept from an account keyed to a different address. +8. If `grantOrgMembership === true`, the server adds org membership with `orgRole` (if the user isn't already a member). +9. Server adds `projectMembers` with `role`. +10. Frontend redirects to the dashboard. ## Active Organization diff --git a/packages/shared/src/email.ts b/packages/shared/src/email.ts index dc8f784ea..07c725ac5 100644 --- a/packages/shared/src/email.ts +++ b/packages/shared/src/email.ts @@ -6,8 +6,17 @@ export interface EmailPayload { html?: string; text?: string; replyTo?: string; + // Lets the queue consumer mark the invitation row when the address is undeliverable + invitationId?: string; } +/** + * What happened to the invitation email. recently_sent is a resend inside the + * cooldown; not_sent covers a queue failure and an address that cannot take + * mail, in both cases the in-app notification is the delivery path. + */ +export type InvitationDelivery = 'queued' | 'recently_sent' | 'not_sent'; + interface EmailQueue { send(payload: EmailPayload): Promise; } diff --git a/packages/shared/src/errors/domains/domain.ts b/packages/shared/src/errors/domains/domain.ts index 2c2010fa8..c03aed23f 100644 --- a/packages/shared/src/errors/domains/domain.ts +++ b/packages/shared/src/errors/domains/domain.ts @@ -138,6 +138,17 @@ export const PROJECT_ERRORS = { defaultMessage: 'This invitation has already been accepted.', statusCode: 400, }, + INVITATION_LIMIT_REACHED: { + code: 'PROJECT_INVITATION_LIMIT_REACHED', + defaultMessage: + 'This project has too many pending invitations. Cancel some before inviting more people.', + statusCode: 429, + }, + INVITATION_RATE_LIMITED: { + code: 'PROJECT_INVITATION_RATE_LIMITED', + defaultMessage: 'You have sent a lot of invitations in the past hour. Try again later.', + statusCode: 429, + }, } as const; export type ProjectErrorCode = (typeof PROJECT_ERRORS)[keyof typeof PROJECT_ERRORS]['code']; diff --git a/packages/web/migrations/0012_invitation_email_status.sql b/packages/web/migrations/0012_invitation_email_status.sql new file mode 100644 index 000000000..e4df65c20 --- /dev/null +++ b/packages/web/migrations/0012_invitation_email_status.sql @@ -0,0 +1,3 @@ +ALTER TABLE `project_invitations` ADD `emailSentAt` integer;--> statement-breakpoint +ALTER TABLE `project_invitations` ADD `emailStatus` text;--> statement-breakpoint +CREATE UNIQUE INDEX `project_invitations_projectId_email_uidx` ON `project_invitations` (`projectId`,`email`); \ No newline at end of file diff --git a/packages/web/migrations/meta/0012_snapshot.json b/packages/web/migrations/meta/0012_snapshot.json new file mode 100644 index 000000000..0961c9fec --- /dev/null +++ b/packages/web/migrations/meta/0012_snapshot.json @@ -0,0 +1,2119 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "92e5ec49-e45a-40a9-873a-e00f060ed2bc", + "prevId": "3ca2539e-6769-4949-9446-65e493124eee", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "account_issuer_accountId_uidx": { + "name": "account_issuer_accountId_uidx", + "columns": [ + "issuer", + "accountId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contact_submissions": { + "name": "contact_submissions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dedupKey": { + "name": "dedupKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "contact_submissions_dedupKey_unique": { + "name": "contact_submissions_dedupKey_unique", + "columns": [ + "dedupKey" + ], + "isUnique": true + }, + "contact_submissions_email_createdAt_idx": { + "name": "contact_submissions_email_createdAt_idx", + "columns": [ + "email", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feedback": { + "name": "feedback", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'new'" + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "feedback_userId_createdAt_idx": { + "name": "feedback_userId_createdAt_idx", + "columns": [ + "userId", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "feedback_userId_user_id_fk": { + "name": "feedback_userId_user_id_fk", + "tableFrom": "feedback", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inviterId": { + "name": "inviterId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_inviterId_user_id_fk": { + "name": "invitation_inviterId_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organizationId_organization_id_fk": { + "name": "invitation_organizationId_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mediaFiles": { + "name": "mediaFiles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originalName": { + "name": "originalName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileType": { + "name": "fileType", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileSize": { + "name": "fileSize", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadedBy": { + "name": "uploadedBy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucketKey": { + "name": "bucketKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "studyId": { + "name": "studyId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "mediaFiles_projectId_idx": { + "name": "mediaFiles_projectId_idx", + "columns": [ + "projectId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "mediaFiles_uploadedBy_user_id_fk": { + "name": "mediaFiles_uploadedBy_user_id_fk", + "tableFrom": "mediaFiles", + "tableTo": "user", + "columnsFrom": [ + "uploadedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mediaFiles_orgId_organization_id_fk": { + "name": "mediaFiles_orgId_organization_id_fk", + "tableFrom": "mediaFiles", + "tableTo": "organization", + "columnsFrom": [ + "orgId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mediaFiles_projectId_projects_id_fk": { + "name": "mediaFiles_projectId_projects_id_fk", + "tableFrom": "mediaFiles", + "tableTo": "projects", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + "organizationId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_userId_user_id_fk": { + "name": "member_userId_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organizationId_organization_id_fk": { + "name": "member_organizationId_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "readAt": { + "name": "readAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notifications_userId_createdAt_idx": { + "name": "notifications_userId_createdAt_idx", + "columns": [ + "userId", + "createdAt" + ], + "isUnique": false + }, + "notifications_userId_readAt_idx": { + "name": "notifications_userId_readAt_idx", + "columns": [ + "userId", + "readAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_userId_user_id_fk": { + "name": "notifications_userId_user_id_fk", + "tableFrom": "notifications", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "org_access_grants": { + "name": "org_access_grants", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "startsAt": { + "name": "startsAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeCheckoutSessionId": { + "name": "stripeCheckoutSessionId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "org_access_grants_stripeCheckoutSessionId_unique": { + "name": "org_access_grants_stripeCheckoutSessionId_unique", + "columns": [ + "stripeCheckoutSessionId" + ], + "isUnique": true + }, + "org_access_grants_orgId_idx": { + "name": "org_access_grants_orgId_idx", + "columns": [ + "orgId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "org_access_grants_orgId_organization_id_fk": { + "name": "org_access_grants_orgId_organization_id_fk", + "tableFrom": "org_access_grants", + "tableTo": "organization", + "columnsFrom": [ + "orgId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "processed_emails": { + "name": "processed_emails", + "columns": { + "queueMessageId": { + "name": "queueMessageId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "processedAt": { + "name": "processedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_invitations": { + "name": "project_invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'member'" + }, + "orgRole": { + "name": "orgRole", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'member'" + }, + "grantOrgMembership": { + "name": "grantOrgMembership", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedBy": { + "name": "invitedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailSentAt": { + "name": "emailSentAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailStatus": { + "name": "emailStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "project_invitations_token_unique": { + "name": "project_invitations_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "project_invitations_projectId_idx": { + "name": "project_invitations_projectId_idx", + "columns": [ + "projectId" + ], + "isUnique": false + }, + "project_invitations_projectId_email_uidx": { + "name": "project_invitations_projectId_email_uidx", + "columns": [ + "projectId", + "email" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_invitations_orgId_organization_id_fk": { + "name": "project_invitations_orgId_organization_id_fk", + "tableFrom": "project_invitations", + "tableTo": "organization", + "columnsFrom": [ + "orgId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_invitations_projectId_projects_id_fk": { + "name": "project_invitations_projectId_projects_id_fk", + "tableFrom": "project_invitations", + "tableTo": "projects", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_invitations_invitedBy_user_id_fk": { + "name": "project_invitations_invitedBy_user_id_fk", + "tableFrom": "project_invitations", + "tableTo": "user", + "columnsFrom": [ + "invitedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_members": { + "name": "project_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'member'" + }, + "joinedAt": { + "name": "joinedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "project_members_projectId_idx": { + "name": "project_members_projectId_idx", + "columns": [ + "projectId" + ], + "isUnique": false + }, + "project_members_userId_idx": { + "name": "project_members_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "project_members_projectId_userId_uidx": { + "name": "project_members_projectId_userId_uidx", + "columns": [ + "projectId", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_members_projectId_projects_id_fk": { + "name": "project_members_projectId_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_userId_user_id_fk": { + "name": "project_members_userId_user_id_fk", + "tableFrom": "project_members", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "setupStep": { + "name": "setupStep", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_orgId_idx": { + "name": "projects_orgId_idx", + "columns": [ + "orgId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "projects_orgId_organization_id_fk": { + "name": "projects_orgId_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "orgId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_createdBy_user_id_fk": { + "name": "projects_createdBy_user_id_fk", + "tableFrom": "projects", + "tableTo": "user", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rateLimit": { + "name": "rateLimit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastRequest": { + "name": "lastRequest", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rateLimit_key_unique": { + "name": "rateLimit_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impersonatedBy": { + "name": "impersonatedBy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_impersonatedBy_user_id_fk": { + "name": "session_impersonatedBy_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "impersonatedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "session_activeOrganizationId_organization_id_fk": { + "name": "session_activeOrganizationId_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": [ + "activeOrganizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stripe_event_ledger": { + "name": "stripe_event_ledger", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payloadHash": { + "name": "payloadHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signaturePresent": { + "name": "signaturePresent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "receivedAt": { + "name": "receivedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "route": { + "name": "route", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'received'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "httpStatus": { + "name": "httpStatus", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeEventId": { + "name": "stripeEventId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "livemode": { + "name": "livemode", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "apiVersion": { + "name": "apiVersion", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created": { + "name": "created", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processedAt": { + "name": "processedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeCheckoutSessionId": { + "name": "stripeCheckoutSessionId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "stripe_event_ledger_payloadHash_unique": { + "name": "stripe_event_ledger_payloadHash_unique", + "columns": [ + "payloadHash" + ], + "isUnique": true + }, + "stripe_event_ledger_stripeEventId_unique": { + "name": "stripe_event_ledger_stripeEventId_unique", + "columns": [ + "stripeEventId" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subscription": { + "name": "subscription", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'incomplete'" + }, + "periodStart": { + "name": "periodStart", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "periodEnd": { + "name": "periodEnd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "cancelAt": { + "name": "cancelAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "canceledAt": { + "name": "canceledAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "endedAt": { + "name": "endedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trialStart": { + "name": "trialStart", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trialEnd": { + "name": "trialEnd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "subscription_referenceId_idx": { + "name": "subscription_referenceId_idx", + "columns": [ + "referenceId" + ], + "isUnique": false + }, + "subscription_referenceId_incomplete_uidx": { + "name": "subscription_referenceId_incomplete_uidx", + "columns": [ + "referenceId" + ], + "isUnique": true, + "where": "\"subscription\".\"status\" = 'incomplete'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "twoFactor": { + "name": "twoFactor", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backupCodes": { + "name": "backupCodes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "twoFactor_userId_user_id_fk": { + "name": "twoFactor_userId_user_id_fk", + "tableFrom": "twoFactor", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "givenName": { + "name": "givenName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "familyName": { + "name": "familyName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatarUrl": { + "name": "avatarUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "persona": { + "name": "persona", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "profileCompletedAt": { + "name": "profileCompletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "institution": { + "name": "institution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "department": { + "name": "department", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferences": { + "name": "preferences", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastActiveAt": { + "name": "lastActiveAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "twoFactorEnabled": { + "name": "twoFactorEnabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "banned": { + "name": "banned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "banReason": { + "name": "banReason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banExpires": { + "name": "banExpires", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/web/migrations/meta/_journal.json b/packages/web/migrations/meta/_journal.json index 1d8c24f07..c3fe83a17 100644 --- a/packages/web/migrations/meta/_journal.json +++ b/packages/web/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1788838105585, "tag": "0011_auth_rate_limit", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1789240765851, + "tag": "0012_invitation_email_status", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/web/src/__tests__/server/migration-sql.js b/packages/web/src/__tests__/server/migration-sql.js index 72a864851..4822ff091 100644 --- a/packages/web/src/__tests__/server/migration-sql.js +++ b/packages/web/src/__tests__/server/migration-sql.js @@ -348,4 +348,8 @@ CREATE TABLE \`rateLimit\` ( \`lastRequest\` integer NOT NULL ); --> statement-breakpoint -CREATE UNIQUE INDEX \`rateLimit_key_unique\` ON \`rateLimit\` (\`key\`);`; +CREATE UNIQUE INDEX \`rateLimit_key_unique\` ON \`rateLimit\` (\`key\`); +--> statement-breakpoint +ALTER TABLE \`project_invitations\` ADD \`emailSentAt\` integer;--> statement-breakpoint +ALTER TABLE \`project_invitations\` ADD \`emailStatus\` text;--> statement-breakpoint +CREATE UNIQUE INDEX \`project_invitations_projectId_email_uidx\` ON \`project_invitations\` (\`projectId\`,\`email\`);`; diff --git a/packages/web/src/components/project/overview-tab/AddMemberModal.tsx b/packages/web/src/components/project/overview-tab/AddMemberModal.tsx index 6e90f7352..3037d7652 100644 --- a/packages/web/src/components/project/overview-tab/AddMemberModal.tsx +++ b/packages/web/src/components/project/overview-tab/AddMemberModal.tsx @@ -7,7 +7,7 @@ import { useState, useEffect, useRef, useId } from 'react'; import { Link } from '@tanstack/react-router'; import { MailIcon, TriangleAlertIcon, XIcon } from 'lucide-react'; -import { isValidEmail, normalizeEmail } from '@corates/shared/email'; +import { isValidEmail, normalizeEmail, type InvitationDelivery } from '@corates/shared/email'; import { showToast } from '@/lib/toast'; import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'; import { Avatar, AvatarImage, AvatarFallback, getInitials } from '@/components/ui/avatar'; @@ -202,15 +202,27 @@ export function AddMemberModal({ { userId: pending.user.id, role } : { email: pending.email, role }), }, - })) as { invitation?: boolean; email?: string }; + })) as { email?: string; delivery?: InvitationDelivery }; clientLogger.info('client.collaborator.invited', { method: 'email' }); const sentTo = result.email || (pending.kind === 'user' ? pending.user.name || pending.user.email : pending.email); - showToast.success( - 'Invitation sent', - `${sentTo} can join the project from the link in the email.`, - ); + if (result.delivery === 'queued') { + showToast.success( + 'Invitation sent', + `${sentTo} can join the project from the link in the email.`, + ); + } else if (result.delivery === 'recently_sent') { + showToast.success( + 'Invitation updated', + `${sentTo} was emailed a link a few minutes ago, so no new email was sent.`, + ); + } else { + showToast.warning( + 'Invitation created, but no email was sent', + `${sentTo} can still accept from their CoRATES notifications. Invite them again later to retry the email.`, + ); + } // The invitations list is a D1 fact read through React Query. Nothing // pushes it to this client, so refetch after the write. queryClient.invalidateQueries({ queryKey: queryKeys.projects.invitations(projectId) }); diff --git a/packages/web/src/components/project/overview-tab/PendingInvitations.tsx b/packages/web/src/components/project/overview-tab/PendingInvitations.tsx index 5a984239b..b1479402e 100644 --- a/packages/web/src/components/project/overview-tab/PendingInvitations.tsx +++ b/packages/web/src/components/project/overview-tab/PendingInvitations.tsx @@ -19,6 +19,7 @@ interface PendingInvitation { role: string; expiresAt: string | Date; createdAt: string | Date; + emailStatus: 'queued' | 'undeliverable' | null; } function expiryText(expiresAt: string | Date): { text: string; expired: boolean } { @@ -74,6 +75,7 @@ export function PendingInvitations({
{invitations.map(invitation => { const expiry = expiryText(invitation.expiresAt); + const undeliverable = invitation.emailStatus === 'undeliverable'; return (
@@ -84,9 +86,9 @@ export function PendingInvitations({ {invitation.email}
- {expiry.text} + {undeliverable ? 'Email could not be delivered' : expiry.text}
diff --git a/packages/web/src/lib/error-utils.ts b/packages/web/src/lib/error-utils.ts index 99e9a2b78..0f5dd498d 100644 --- a/packages/web/src/lib/error-utils.ts +++ b/packages/web/src/lib/error-utils.ts @@ -45,6 +45,10 @@ const USER_FRIENDLY_MESSAGES: Record = { PROJECT_LAST_OWNER: 'Projects must have at least one owner', PROJECT_INVALID_ROLE: 'Please select a valid role', PROJECT_INVITATION_ALREADY_ACCEPTED: 'This invitation has already been accepted', + PROJECT_INVITATION_LIMIT_REACHED: + 'This project has too many pending invitations. Cancel some before inviting more people.', + PROJECT_INVITATION_RATE_LIMITED: + 'You have sent a lot of invitations in the past hour. Try again later.', // File errors FILE_TOO_LARGE: 'This file is too large. Please choose a smaller file.', diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 47f339a83..fe6de9bf0 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -125,7 +125,10 @@ const workerHandler = { env: (env as SentryEnv).ENVIRONMENT, context: { queue: isDeadLetter ? 'email-dlq' : 'email', batchSize: batch.messages.length }, }, - () => (isDeadLetter ? handleEmailDeadLetter(batch) : handleEmailQueue(batch, env as never)), + () => + isDeadLetter ? + handleEmailDeadLetter(batch, env as never) + : handleEmailQueue(batch, env as never), ); }, }; diff --git a/packages/web/src/server/functions/__tests__/invitations.server.test.ts b/packages/web/src/server/functions/__tests__/invitations.server.test.ts index 294adb63a..33e135e95 100644 --- a/packages/web/src/server/functions/__tests__/invitations.server.test.ts +++ b/packages/web/src/server/functions/__tests__/invitations.server.test.ts @@ -374,10 +374,25 @@ describe('listPendingInvitationsForUser', () => { project: { name: 'Pending Project' }, }); + // One row per project and address, so the dead states live on sibling projects + const stale = await buildProject({ owner: inviter }); + const done = await buildProject({ owner: inviter }); const base = { orgId: org.id, projectId: project.id, invitedBy: inviter.id }; const pending = await buildProjectInvitation({ ...base, email: 'invitee@example.com' }); - await buildProjectInvitation({ ...base, email: 'invitee@example.com', status: 'expired' }); - await buildProjectInvitation({ ...base, email: 'invitee@example.com', status: 'accepted' }); + await buildProjectInvitation({ + orgId: stale.org.id, + projectId: stale.project.id, + invitedBy: inviter.id, + email: 'invitee@example.com', + status: 'expired', + }); + await buildProjectInvitation({ + orgId: done.org.id, + projectId: done.project.id, + invitedBy: inviter.id, + email: 'invitee@example.com', + status: 'accepted', + }); await buildProjectInvitation({ ...base, email: 'someone-else@example.com' }); currentUser = { id: 'invitee-user', email: 'Invitee@Example.com' }; diff --git a/packages/web/src/server/functions/__tests__/org-projects-members.server.test.ts b/packages/web/src/server/functions/__tests__/org-projects-members.server.test.ts index a14b8a69d..1f8e76610 100644 --- a/packages/web/src/server/functions/__tests__/org-projects-members.server.test.ts +++ b/packages/web/src/server/functions/__tests__/org-projects-members.server.test.ts @@ -9,11 +9,14 @@ import { resetTestDatabase } from '@/__tests__/server/helpers'; import { buildProjectWithMembers, buildProject, + buildProjectInvitation, buildSelfRemovalScenario, buildOrgMember, resetCounter, asUserId, } from '@/__tests__/server/factories'; +import { resolveOrgAccess } from '@corates/workers/billing-resolver'; +import { INVITATION_LIMITS } from '@corates/workers/constants'; import type { Session } from '@/server/middleware/auth'; import { DomainErrorException } from '@corates/shared'; import { @@ -218,6 +221,122 @@ describe('addProjectMember', () => { } }); + it('reports whether the email was queued', async () => { + const { project, org, owner } = await buildProject(); + currentUser = { id: owner.id, email: owner.email }; + const db = createDb(env.DB); + + const first = (await addProjectMember(mockSession(), db, org.id, project.id, { + email: 'fresh@example.com', + })) as { delivery: string }; + expect(first.delivery).toBe('queued'); + + const row = await db + .select({ + emailStatus: projectInvitations.emailStatus, + sentAt: projectInvitations.emailSentAt, + }) + .from(projectInvitations) + .where(eq(projectInvitations.email, 'fresh@example.com')) + .get(); + expect(row?.emailStatus).toBe('queued'); + expect(row?.sentAt).toBeInstanceOf(Date); + + // Re-inviting right away updates the row but does not send another email + const again = (await addProjectMember(mockSession(), db, org.id, project.id, { + email: 'fresh@example.com', + role: 'owner', + })) as { delivery: string }; + expect(again.delivery).toBe('recently_sent'); + + const rows = await db + .select({ role: projectInvitations.role }) + .from(projectInvitations) + .where(eq(projectInvitations.email, 'fresh@example.com')); + expect(rows).toEqual([{ role: 'owner' }]); + }); + + it('keeps one row when the same address is invited twice at once', async () => { + const { project, org, owner } = await buildProject(); + currentUser = { id: owner.id, email: owner.email }; + const db = createDb(env.DB); + + const results = await Promise.all([ + addProjectMember(mockSession(), db, org.id, project.id, { email: 'twice@example.com' }), + addProjectMember(mockSession(), db, org.id, project.id, { email: 'twice@example.com' }), + ]); + expect(results.map(r => (r as { invitation: boolean }).invitation)).toEqual([true, true]); + + const rows = await db + .select({ id: projectInvitations.id }) + .from(projectInvitations) + .where(eq(projectInvitations.email, 'twice@example.com')); + expect(rows).toHaveLength(1); + }); + + it('caps live invitations per project', async () => { + const { project, org, owner } = await buildProject(); + currentUser = { id: owner.id, email: owner.email }; + + for (let i = 0; i < INVITATION_LIMITS.MAX_PENDING_PER_PROJECT; i++) { + await buildProjectInvitation({ orgId: org.id, projectId: project.id, invitedBy: owner.id }); + } + + await expect( + addProjectMember(mockSession(), createDb(env.DB), org.id, project.id, { + email: 'one-too-many@example.com', + }), + ).rejects.toMatchObject({ statusCode: 429 }); + }); + + it('caps invitations created per inviter per hour', async () => { + const { project, org, owner } = await buildProject(); + const other = await buildProject({ owner }); + currentUser = { id: owner.id, email: owner.email }; + + // Spread across two projects so the per-project cap is not what trips + for (let i = 0; i < INVITATION_LIMITS.MAX_CREATED_PER_INVITER_PER_HOUR; i++) { + await buildProjectInvitation({ + orgId: i % 2 ? org.id : other.org.id, + projectId: i % 2 ? project.id : other.project.id, + invitedBy: owner.id, + }); + } + + await expect( + addProjectMember(mockSession(), createDb(env.DB), org.id, project.id, { + email: 'one-too-many@example.com', + }), + ).rejects.toMatchObject({ statusCode: 429 }); + }); + + it('counts live invitations against the collaborator quota', async () => { + const { project, org, owner } = await buildProject(); + const { user: existing } = await buildOrgMember({ orgId: org.id, role: 'member' }); + currentUser = { id: owner.id, email: owner.email }; + vi.mocked(resolveOrgAccess).mockResolvedValue({ + accessMode: 'write', + source: 'free', + quotas: { 'projects.max': 10, 'collaborators.org.max': 2 }, + entitlements: { 'project.create': true }, + } as never); + + await buildProjectInvitation({ orgId: org.id, projectId: project.id, invitedBy: owner.id }); + + // One member plus one pending invitation fills a quota of two + await expect( + addProjectMember(mockSession(), createDb(env.DB), org.id, project.id, { + email: 'third@example.com', + }), + ).rejects.toMatchObject({ statusCode: 403 }); + + // Someone already in the workspace takes no seat + const result = (await addProjectMember(mockSession(), createDb(env.DB), org.id, project.id, { + userId: existing.id, + })) as { invitation: boolean }; + expect(result.invitation).toBe(true); + }); + it('defaults invitation role to member', async () => { const { project, org, owner } = await buildProject(); const { user: newMember } = await buildOrgMember({ orgId: org.id, role: 'member' }); diff --git a/packages/web/src/server/functions/invitations.server.ts b/packages/web/src/server/functions/invitations.server.ts index 8519cd258..fb4d0a6d1 100644 --- a/packages/web/src/server/functions/invitations.server.ts +++ b/packages/web/src/server/functions/invitations.server.ts @@ -17,6 +17,7 @@ import { import type { Database } from '@corates/db/client'; import { projectInvitations, projects, user } from '@corates/db/schema'; import { and, desc, eq, gt, isNull } from 'drizzle-orm'; +import { normalizeEmail } from '@corates/shared/email'; import type { Session } from '@/server/middleware/auth'; export interface AcceptResult { @@ -119,7 +120,7 @@ export async function listPendingInvitationsForUser( .leftJoin(user, eq(user.id, projectInvitations.invitedBy)) .where( and( - eq(projectInvitations.email, session.user.email.toLowerCase()), + eq(projectInvitations.email, normalizeEmail(session.user.email)), isNull(projectInvitations.acceptedAt), gt(projectInvitations.expiresAt, new Date()), ), @@ -150,7 +151,7 @@ export async function declineInvitation( .where(eq(projectInvitations.id, invitationId)) .get(); - if (!invitation || invitation.email !== session.user.email.toLowerCase()) { + if (!invitation || invitation.email !== normalizeEmail(session.user.email)) { throw new DomainErrorException( createDomainError(VALIDATION_ERRORS.FIELD_INVALID_FORMAT, { field: 'invitationId', diff --git a/packages/web/src/server/functions/org-projects.server.ts b/packages/web/src/server/functions/org-projects.server.ts index b66cece90..4cce6fa27 100644 --- a/packages/web/src/server/functions/org-projects.server.ts +++ b/packages/web/src/server/functions/org-projects.server.ts @@ -1,8 +1,8 @@ import { captureError, info } from '@corates/workers/logger'; import { env } from 'cloudflare:workers'; import type { Database } from '@corates/db/client'; -import { projects, projectMembers, projectInvitations, user } from '@corates/db/schema'; -import { eq, and, count, desc, isNull } from 'drizzle-orm'; +import { projects, projectMembers, projectInvitations, user, member } from '@corates/db/schema'; +import { eq, and, count, desc, gt, isNull, ne, notExists, sql } from 'drizzle-orm'; import { DomainErrorException, isDomainError, @@ -282,7 +282,7 @@ export async function addProjectMember( userToAdd = await db .select({ id: user.id, email: user.email }) .from(user) - .where(eq(user.email, email)) + .where(eq(sql`lower(${user.email})`, email)) .get(); } @@ -307,6 +307,23 @@ export async function addProjectMember( throwDomainError(VALIDATION_ERRORS.FIELD_REQUIRED, { field: 'email' }); } + // Accepting is what consumes a collaborator seat, but the invite modal is + // the only entry point, so the plan cap is applied here too. Someone + // already in the workspace takes no new seat. + const orgMember = + userToAdd && + (await db + .select({ id: member.id }) + .from(member) + .where(and(eq(member.organizationId, orgId), eq(member.userId, userToAdd.id))) + .get()); + if (!orgMember) { + const quota = await requireQuota(db, orgId, 'collaborators.org.max', () => + countCollaboratorSeats(db, orgId), + ); + if (!quota.ok) throw quota.error; + } + const result = await createInvitation( env, { id: access.context.userId }, @@ -316,10 +333,7 @@ export async function addProjectMember( return { success: true, invitation: true, - message: - result.emailQueued ? - 'Invitation sent successfully' - : 'Invitation created but email delivery may be delayed', + delivery: result.delivery, email: inviteEmail, }; } catch (err) { @@ -336,6 +350,39 @@ export async function addProjectMember( } } +// Seats in use: non-owner members plus live invitations to people not yet in the workspace +async function countCollaboratorSeats(db: Database, orgId: OrgId): Promise { + const [members] = await db + .select({ count: count() }) + .from(member) + .where(and(eq(member.organizationId, orgId), ne(member.role, 'owner'))); + + const [pending] = await db + .select({ count: count() }) + .from(projectInvitations) + .where( + and( + eq(projectInvitations.orgId, orgId), + isNull(projectInvitations.acceptedAt), + gt(projectInvitations.expiresAt, new Date()), + notExists( + db + .select({ id: member.id }) + .from(member) + .innerJoin(user, eq(user.id, member.userId)) + .where( + and( + eq(member.organizationId, orgId), + eq(sql`lower(${user.email})`, projectInvitations.email), + ), + ), + ), + ), + ); + + return (members?.count ?? 0) + (pending?.count ?? 0); +} + export async function removeProjectMember( session: Session, db: Database, @@ -411,6 +458,7 @@ export async function listProjectInvitations( orgRole: projectInvitations.orgRole, expiresAt: projectInvitations.expiresAt, acceptedAt: projectInvitations.acceptedAt, + emailStatus: projectInvitations.emailStatus, createdAt: projectInvitations.createdAt, invitedBy: projectInvitations.invitedBy, }) diff --git a/packages/workers/queue.d.ts b/packages/workers/queue.d.ts index be83b8787..c930ef400 100644 --- a/packages/workers/queue.d.ts +++ b/packages/workers/queue.d.ts @@ -5,4 +5,4 @@ // workers Env type; the runtime cast happens inside packages/web/src/server.ts. export declare function handleEmailQueue(batch: unknown, env: unknown): Promise; -export declare function handleEmailDeadLetter(batch: unknown): Promise; +export declare function handleEmailDeadLetter(batch: unknown, env: unknown): Promise; diff --git a/packages/workers/src/auth/email.ts b/packages/workers/src/auth/email.ts index 57abe03b7..0ee515929 100644 --- a/packages/workers/src/auth/email.ts +++ b/packages/workers/src/auth/email.ts @@ -23,8 +23,13 @@ interface EmailResult { success: boolean; id?: string; error?: string; + /** The address itself is rejected; retrying can never succeed */ + permanent?: boolean; } +// Postmark API codes: 300 malformed recipient, 406 recipient on the suppression list +const PERMANENT_POSTMARK_CODES = new Set([300, 406]); + interface EmailService { sendEmail: (_params: SendEmailParams) => Promise; isProduction: boolean; @@ -83,9 +88,10 @@ export function createEmailService(env: Env): EmailService { return { success: true, id: response.MessageID }; } catch (err) { - const error = err as Error; - captureError(err, { tags: { component: 'email' } }); - return { success: false, error: error.message }; + const error = err as Error & { code?: unknown }; + const permanent = typeof error.code === 'number' && PERMANENT_POSTMARK_CODES.has(error.code); + captureError(err, { tags: { component: 'email' }, extra: { permanent } }); + return { success: false, error: error.message, permanent }; } } diff --git a/packages/workers/src/commands/invitations/createInvitation.ts b/packages/workers/src/commands/invitations/createInvitation.ts index 4f17fae03..604ea635a 100644 --- a/packages/workers/src/commands/invitations/createInvitation.ts +++ b/packages/workers/src/commands/invitations/createInvitation.ts @@ -1,15 +1,20 @@ /** * Create or resend a project invitation * - * Handles: existing invitation check, token generation, insert/update, email sending + * Handles: existing invitation check, send caps, token generation, + * insert/update, email sending + * + * @throws DomainError PROJECT_INVITATION_LIMIT_REACHED when the project has too many live invitations + * @throws DomainError PROJECT_INVITATION_RATE_LIMITED when the inviter has created too many in an hour */ import { captureError, info } from '../../lib/logger'; -import { createDb } from '@corates/db/client'; +import { createDb, type Database } from '@corates/db/client'; import { projectInvitations, projects, user } from '@corates/db/schema'; -import { eq, and, sql } from 'drizzle-orm'; -import { isSyntheticEmail, normalizeEmail } from '@corates/shared/email'; -import { TIME_DURATIONS } from '../../config/constants'; +import { eq, and, count, gt, isNull, sql } from 'drizzle-orm'; +import { createDomainError, PROJECT_ERRORS } from '@corates/shared'; +import { isSyntheticEmail, normalizeEmail, type InvitationDelivery } from '@corates/shared/email'; +import { INVITATION_LIMITS, TIME_DURATIONS } from '../../config/constants'; import type { Env } from '../../types'; import { createNotification } from '../notifications'; @@ -26,43 +31,105 @@ interface CreateInvitationParams { interface CreateInvitationResult { invitationId: string; - emailQueued: boolean; + delivery: InvitationDelivery; } -export async function createInvitation( - env: Env, - actor: CreateInvitationActor, - { orgId, projectId, email, role }: CreateInvitationParams, -): Promise { - const db = createDb(env.DB); - const normalizedEmail = normalizeEmail(email); - - const existingInvitation = await db +function findExisting(db: Database, projectId: string, email: string) { + return db .select({ id: projectInvitations.id, token: projectInvitations.token, acceptedAt: projectInvitations.acceptedAt, + emailSentAt: projectInvitations.emailSentAt, }) .from(projectInvitations) + .where(and(eq(projectInvitations.projectId, projectId), eq(projectInvitations.email, email))) + .get(); +} + +async function assertWithinSendCaps(db: Database, projectId: string, inviterId: string) { + const now = new Date(); + + const [pending] = await db + .select({ count: count() }) + .from(projectInvitations) .where( and( eq(projectInvitations.projectId, projectId), - eq(projectInvitations.email, normalizedEmail), + isNull(projectInvitations.acceptedAt), + gt(projectInvitations.expiresAt, now), ), - ) - .get(); + ); + if ((pending?.count ?? 0) >= INVITATION_LIMITS.MAX_PENDING_PER_PROJECT) { + throw createDomainError(PROJECT_ERRORS.INVITATION_LIMIT_REACHED, { + projectId, + limit: INVITATION_LIMITS.MAX_PENDING_PER_PROJECT, + }); + } + + const [recent] = await db + .select({ count: count() }) + .from(projectInvitations) + .where( + and( + eq(projectInvitations.invitedBy, inviterId), + gt(projectInvitations.createdAt, new Date(now.getTime() - TIME_DURATIONS.ONE_HOUR_MS)), + ), + ); + if ((recent?.count ?? 0) >= INVITATION_LIMITS.MAX_CREATED_PER_INVITER_PER_HOUR) { + throw createDomainError(PROJECT_ERRORS.INVITATION_RATE_LIMITED, { + limit: INVITATION_LIMITS.MAX_CREATED_PER_INVITER_PER_HOUR, + }); + } +} - let token: string; - let invitationId: string; +export async function createInvitation( + env: Env, + actor: CreateInvitationActor, + { orgId, projectId, email, role }: CreateInvitationParams, +): Promise { + const db = createDb(env.DB); + const normalizedEmail = normalizeEmail(email); + const expiresAt = new Date(Date.now() + TIME_DURATIONS.INVITATION_EXPIRY_MS); + + let existing = await findExisting(db, projectId, normalizedEmail); + let invitationId: string = crypto.randomUUID(); + let token: string = crypto.randomUUID(); + + if (!existing) { + await assertWithinSendCaps(db, projectId, actor.id); - if (existingInvitation) { + const inserted = await db + .insert(projectInvitations) + .values({ + id: invitationId, + orgId, + projectId, + email: normalizedEmail, + role, + orgRole: 'member', + grantOrgMembership: true, + token, + invitedBy: actor.id, + expiresAt, + createdAt: new Date(), + }) + .onConflictDoNothing() + .returning({ id: projectInvitations.id }); + + // A concurrent request for the same address won the insert; treat ours as a resend + if (inserted.length === 0) { + existing = await findExisting(db, projectId, normalizedEmail); + } + } + + if (existing) { // Resend: update role and extend expiration. A previously accepted // invitation is reset with a fresh token so someone who was removed from // the project can be invited again (accepting is the only way back in); // the old emailed link stays dead because the token changes. - invitationId = existingInvitation.id; - token = existingInvitation.acceptedAt ? crypto.randomUUID() : existingInvitation.token; - const expiresAt = new Date(Date.now() + TIME_DURATIONS.INVITATION_EXPIRY_MS); + invitationId = existing.id; + token = existing.acceptedAt ? crypto.randomUUID() : existing.token; await db .update(projectInvitations) @@ -74,25 +141,7 @@ export async function createInvitation( acceptedAt: null, expiresAt, }) - .where(eq(projectInvitations.id, existingInvitation.id)); - } else { - invitationId = crypto.randomUUID(); - token = crypto.randomUUID(); - const expiresAt = new Date(Date.now() + TIME_DURATIONS.INVITATION_EXPIRY_MS); - - await db.insert(projectInvitations).values({ - id: invitationId, - orgId, - projectId, - email: normalizedEmail, - role, - orgRole: 'member', - grantOrgMembership: true, - token, - invitedBy: actor.id, - expiresAt, - createdAt: new Date(), - }); + .where(eq(projectInvitations.id, existing.id)); } // Fetch context for email @@ -111,10 +160,17 @@ export async function createInvitation( const projectName = project?.name || 'Unknown Project'; const inviterName = inviter?.givenName || inviter?.name || inviter?.email || 'Someone'; - let emailQueued = false; - try { - // Synthetic ORCID addresses bounce and poison sender reputation - if (!isSyntheticEmail(normalizedEmail)) { + const recentlySent = + !!existing?.emailSentAt && + Date.now() - existing.emailSentAt.getTime() < INVITATION_LIMITS.RESEND_COOLDOWN_MS; + + // Synthetic ORCID addresses bounce and poison sender reputation; the + // in-app notification below still reaches those accounts. + let delivery: InvitationDelivery = 'not_sent'; + if (recentlySent) { + delivery = 'recently_sent'; + } else if (!isSyntheticEmail(normalizedEmail)) { + try { const { sendInvitationEmail } = await import('../../lib/send-invitation-email.js'); const result = await sendInvitationEmail({ env, @@ -123,14 +179,21 @@ export async function createInvitation( projectName, inviterName, role, + invitationId, + }); + if (result.emailQueued) { + delivery = 'queued'; + await db + .update(projectInvitations) + .set({ emailSentAt: new Date(), emailStatus: 'queued' }) + .where(eq(projectInvitations.id, invitationId)); + } + } catch (err) { + captureError(err, { + tags: { component: 'invitation', action: 'send-email' }, + extra: { projectId }, }); - emailQueued = result.emailQueued; } - } catch (err) { - captureError(err, { - tags: { component: 'invitation', action: 'send-email' }, - extra: { projectId }, - }); } // An invitee who already has an account also gets the invitation in-app. @@ -149,7 +212,7 @@ export async function createInvitation( }); } - info('invitation.created', { orgId, projectId, invitationId, role, emailQueued }); + info('invitation.created', { orgId, projectId, invitationId, role, delivery }); - return { invitationId, emailQueued }; + return { invitationId, delivery }; } diff --git a/packages/workers/src/config/constants.ts b/packages/workers/src/config/constants.ts index 0d2071fae..72b38cb11 100644 --- a/packages/workers/src/config/constants.ts +++ b/packages/workers/src/config/constants.ts @@ -19,3 +19,11 @@ export const TIME_DURATIONS = { ONE_HOUR_MS: 60 * 60 * 1000, ONE_HOUR_SEC: 60 * 60, } as const; + +// Invitation emails go out through the shared Postmark account, so one project +// owner must not be able to turn the invite form into a bulk sender. +export const INVITATION_LIMITS = { + MAX_PENDING_PER_PROJECT: 20, + MAX_CREATED_PER_INVITER_PER_HOUR: 30, + RESEND_COOLDOWN_MS: 10 * 60 * 1000, +} as const; diff --git a/packages/workers/src/lib/__tests__/email-queue.test.ts b/packages/workers/src/lib/__tests__/email-queue.test.ts index 63611d73c..b927bf106 100644 --- a/packages/workers/src/lib/__tests__/email-queue.test.ts +++ b/packages/workers/src/lib/__tests__/email-queue.test.ts @@ -36,23 +36,36 @@ function createMockMessage(payload: EmailPayload, attempts = 0) { } // In-memory stand-in for the processed_emails table so tests exercise the -// real dedup behavior: SELECT reads the set, INSERT adds to it. +// real dedup behavior: SELECT reads the set, INSERT adds to it. Every other +// statement (the invitation status update) is recorded for assertions. function createMockDb() { const processed = new Set(); + const statements: { sql: string; params: unknown[] }[] = []; return { + statements, prepare: (sql: string) => ({ - bind: (id: string) => ({ - first: () => Promise.resolve(processed.has(id) ? { 1: 1 } : null), - run: () => { - const changes = sql.startsWith('INSERT') && !processed.has(id) ? 1 : 0; - if (sql.startsWith('INSERT')) processed.add(id); - return Promise.resolve({ meta: { changes } }); - }, - }), + bind: (...params: unknown[]) => { + const id = params[0] as string; + statements.push({ sql, params }); + return { + first: () => Promise.resolve(processed.has(id) ? { 1: 1 } : null), + run: () => { + const changes = sql.startsWith('INSERT') && !processed.has(id) ? 1 : 0; + if (sql.startsWith('INSERT')) processed.add(id); + return Promise.resolve({ meta: { changes }, success: true }); + }, + }; + }, }), }; } +function undeliverableUpdates(db: ReturnType) { + return db.statements.filter( + s => /update "project_invitations"/i.test(s.sql) && s.params.includes('undeliverable'), + ); +} + function createMockBatch(messages: ReturnType[]) { return { messages, @@ -103,6 +116,37 @@ describe('Email Queue Consumer', () => { expect(mockSendEmail).toHaveBeenCalledTimes(100); }); + it('acks a permanently rejected address without retrying and flags the invitation', async () => { + mockSendEmail.mockResolvedValue({ + success: false, + permanent: true, + error: 'Inactive recipient', + }); + const db = createMockDb(); + const env = { ...(testEnv as object), DB: db } as never; + + const msg = createMockMessage({ ...makePayload(0), invitationId: 'inv-1' }); + await workerHandler.queue(createMockBatch([msg]), env); + + expect(msg.ack).toHaveBeenCalledTimes(1); + expect(msg.retry).not.toHaveBeenCalled(); + const updates = undeliverableUpdates(db); + expect(updates).toHaveLength(1); + expect(updates[0].params).toContain('inv-1'); + }); + + it('does not touch the database for a permanent failure with no invitation', async () => { + mockSendEmail.mockResolvedValue({ success: false, permanent: true, error: 'Bad address' }); + const db = createMockDb(); + const env = { ...(testEnv as object), DB: db } as never; + + const msg = createMockMessage(makePayload(0)); + await workerHandler.queue(createMockBatch([msg]), env); + + expect(msg.ack).toHaveBeenCalledTimes(1); + expect(undeliverableUpdates(db)).toHaveLength(0); + }); + it('should retry with exponential backoff capped at 1800s', async () => { mockSendEmail.mockResolvedValue({ success: false, error: 'Transient failure' }); @@ -266,9 +310,13 @@ describe('Dead-letter consumer', () => { }); const { handleEmailDeadLetter } = await import('../../queue.js'); - const messages = [createMockMessage(makePayload(0)), createMockMessage(makePayload(1))]; + const db = createMockDb(); + const messages = [ + createMockMessage({ ...makePayload(0), invitationId: 'inv-dead' }), + createMockMessage(makePayload(1)), + ]; - await handleEmailDeadLetter(createMockBatch(messages)); + await handleEmailDeadLetter(createMockBatch(messages), { DB: db } as never); expect(logged.map(e => e.message)).toEqual(['email.dead_lettered', 'email.dead_lettered']); expect(logged[0]).toMatchObject({ to: 'user0@example.com', subject: 'Test email 0' }); @@ -276,6 +324,9 @@ describe('Dead-letter consumer', () => { expect(msg.ack).toHaveBeenCalledTimes(1); expect(msg.retry).not.toHaveBeenCalled(); } + const updates = undeliverableUpdates(db); + expect(updates).toHaveLength(1); + expect(updates[0].params).toContain('inv-dead'); warnSpy.mockRestore(); }); diff --git a/packages/workers/src/lib/send-invitation-email.ts b/packages/workers/src/lib/send-invitation-email.ts index b4f9b7eb4..ad8c3bddf 100644 --- a/packages/workers/src/lib/send-invitation-email.ts +++ b/packages/workers/src/lib/send-invitation-email.ts @@ -19,6 +19,7 @@ interface SendInvitationEmailParams { projectName: string; inviterName: string; role: string; + invitationId: string; } interface SendInvitationEmailResult { @@ -34,7 +35,7 @@ interface SendInvitationEmailResult { export async function sendInvitationEmail( params: SendInvitationEmailParams, ): Promise { - const { env, email, token, projectName, inviterName, role } = params; + const { env, email, token, projectName, inviterName, role, invitationId } = params; const invitationUrl = buildAppUrl(env, `/invite/${token}`); @@ -85,6 +86,7 @@ export async function sendInvitationEmail( subject: `${safeInviterName} invited you to "${safeProjectName}" on CoRATES`, html: emailHtml, text: emailText, + invitationId, }); info('invitation.email_queued', { email, projectName }); return { emailQueued: true }; diff --git a/packages/workers/src/queue.ts b/packages/workers/src/queue.ts index 6ace8830a..aaf002691 100644 --- a/packages/workers/src/queue.ts +++ b/packages/workers/src/queue.ts @@ -5,6 +5,9 @@ */ import { captureError, info, runWithContext, warn } from './lib/logger'; import { createEmailService } from './auth/email'; +import { createDb } from '@corates/db/client'; +import { projectInvitations } from '@corates/db/schema'; +import { eq } from 'drizzle-orm'; import type { EmailPayload } from '@corates/shared/email'; import type { Env } from './types'; @@ -29,6 +32,16 @@ async function markProcessed(db: D1Database, messageId: string): Promise { .run(); } +// Surfaces the failure on the pending-invitations list, since nothing else +// tells the inviter that the address never received anything. +async function markInvitationUndeliverable(env: Env, payload: EmailPayload): Promise { + if (!payload.invitationId) return; + await createDb(env.DB) + .update(projectInvitations) + .set({ emailStatus: 'undeliverable' }) + .where(eq(projectInvitations.id, payload.invitationId)); +} + export async function handleEmailQueue(batch: MessageBatch, env: Env): Promise { const emailService = createEmailService(env); const messages = batch.messages as Message[]; @@ -60,10 +73,20 @@ export async function handleEmailQueue(batch: MessageBatch, env: Env): captureError(new Error(`Email send failed for ${msg.body.to}: ${result.error}`), { tags: { component: 'email-queue' }, // subject is all that tells an invitation from a magic link here - extra: { attempt: msg.attempts, to: msg.body.to, subject: msg.body.subject }, + extra: { + attempt: msg.attempts, + to: msg.body.to, + subject: msg.body.subject, + permanent: result.permanent, + }, }); - const delay = Math.min(30 * 2 ** msg.attempts, 1800); - msg.retry({ delaySeconds: delay }); + if (result.permanent) { + await markInvitationUndeliverable(env, msg.body); + msg.ack(); + } else { + const delay = Math.min(30 * 2 ** msg.attempts, 1800); + msg.retry({ delaySeconds: delay }); + } } } catch (error) { captureError(error, { @@ -78,8 +101,8 @@ export async function handleEmailQueue(batch: MessageBatch, env: Env): ); } -// A dead-lettered message is already undeliverable; recording it is all that is left -export async function handleEmailDeadLetter(batch: MessageBatch): Promise { +// A dead-lettered message has exhausted its retries; record it and flag the invitation +export async function handleEmailDeadLetter(batch: MessageBatch, env: Env): Promise { for (const msg of batch.messages as Message[]) { warn('email.dead_lettered', { to: msg.body.to, @@ -87,6 +110,11 @@ export async function handleEmailDeadLetter(batch: MessageBatch): Promi queueMessageId: msg.id, enqueuedAt: msg.timestamp.toISOString(), }); + try { + await markInvitationUndeliverable(env, msg.body); + } catch (error) { + captureError(error, { tags: { component: 'email-dlq' }, extra: { to: msg.body.to } }); + } msg.ack(); } } From 0fc05a6c3c174aaa12b0dd7655a6f095a78cc26a Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 12 Sep 2026 14:34:14 -0500 Subject: [PATCH 2/5] Scope invitee search to the workspace searchUsers matched name, username, and email substrings across the whole user table for any signed-in caller, with full addresses returned whenever the query contained an at sign. A query like "@gmail.com" listed unrelated users' emails, and varying the query walked the table. The search now requires an orgId, checks the caller is a member of that workspace, joins on the member table, and matches on name and username only. People outside the workspace are invited by typing their address, which the modal already supports. Emails of workspace members are returned unmasked since colleagues can already see each other. Claude-Session: https://claude.ai/code/session_014whY28tH1iAuRFLhWA4c5p --- .../src/components/dev/DevImportProject.tsx | 10 +- .../project/overview-tab/AddMemberModal.tsx | 6 +- .../__tests__/users-search.server.test.ts | 159 +++++++++--------- .../src/server/functions/users.functions.ts | 1 + .../web/src/server/functions/users.server.ts | 39 +++-- 5 files changed, 107 insertions(+), 108 deletions(-) diff --git a/packages/web/src/components/dev/DevImportProject.tsx b/packages/web/src/components/dev/DevImportProject.tsx index a254fba2f..5456a7cec 100644 --- a/packages/web/src/components/dev/DevImportProject.tsx +++ b/packages/web/src/components/dev/DevImportProject.tsx @@ -436,6 +436,7 @@ export function DevImportProject() { { - if (debouncedQuery.length < 2) { + if (debouncedQuery.length < 2 || !orgId) { setResults([]); setSearching(false); return; @@ -604,7 +608,7 @@ function UserSearchField({ (async () => { setSearching(true); try { - const data = await searchUsers({ data: { q: debouncedQuery } }); + const data = await searchUsers({ data: { q: debouncedQuery, orgId } }); if (!cancelled) setResults(data as SearchResult[]); } catch { if (!cancelled) setResults([]); @@ -615,7 +619,7 @@ function UserSearchField({ return () => { cancelled = true; }; - }, [debouncedQuery]); + }, [debouncedQuery, orgId]); useEffect(() => { const handler = (e: MouseEvent) => { diff --git a/packages/web/src/components/project/overview-tab/AddMemberModal.tsx b/packages/web/src/components/project/overview-tab/AddMemberModal.tsx index 3037d7652..0a5449394 100644 --- a/packages/web/src/components/project/overview-tab/AddMemberModal.tsx +++ b/packages/web/src/components/project/overview-tab/AddMemberModal.tsx @@ -96,13 +96,13 @@ export function AddMemberModal({ }, [isOpen]); useEffect(() => { - if (!settled || debouncedQuery.length < 2) return; + if (!settled || debouncedQuery.length < 2 || !orgId) return; let cancelled = false; setSearching(true); (async () => { try { const found = await searchUsers({ - data: { q: debouncedQuery, projectId: projectId || undefined }, + data: { q: debouncedQuery, orgId, projectId: projectId || undefined }, }); if (cancelled) return; setResults(found); @@ -119,7 +119,7 @@ export function AddMemberModal({ cancelled = true; setSearching(false); }; - }, [settled, debouncedQuery, projectId]); + }, [settled, debouncedQuery, orgId, projectId]); // A typed email with no matching account is offered as its own row once the // search has settled, so it does not flash before the account row arrives. diff --git a/packages/web/src/server/functions/__tests__/users-search.server.test.ts b/packages/web/src/server/functions/__tests__/users-search.server.test.ts index d13a73498..d5ca27d18 100644 --- a/packages/web/src/server/functions/__tests__/users-search.server.test.ts +++ b/packages/web/src/server/functions/__tests__/users-search.server.test.ts @@ -5,6 +5,8 @@ import { createDb } from '@corates/db/client'; import { resetTestDatabase } from '@/__tests__/server/helpers'; import { buildUser, + buildOrg, + buildOrgMember, buildProject, buildProjectMember, resetCounter, @@ -28,81 +30,116 @@ beforeEach(async () => { const dummyRequest = new Request('http://localhost/api/users/search'); -describe('GET /api/users/search', () => { - it('searches users by email', async () => { - const me = await buildUser({ email: 'user1@example.com' }); - const user2 = await buildUser({ email: 'user2@example.com' }); - await buildUser({ email: 'user3@example.com' }); - currentUser = { id: me.id, email: me.email }; +async function buildWorkspace() { + const { org, owner } = await buildOrg(); + currentUser = { id: owner.id, email: owner.email }; + return { org, owner }; +} + +describe('searchUsers', () => { + it('finds workspace members by name, given name, and username', async () => { + const { org } = await buildWorkspace(); + const byName = await buildUser({ name: 'John Doe' }); + const byGiven = await buildUser({ givenName: 'Johnny' }); + const byUsername = await buildUser({ username: 'johndoe' }); + for (const user of [byName, byGiven, byUsername]) { + await buildOrgMember({ orgId: org.id, user }); + } const result = await searchUsers(createDb(env.DB), mockSession(), dummyRequest, { - q: 'user2', + q: 'JOHN', + orgId: org.id, }); - expect(result).toHaveLength(1); - expect(result[0].id).toBe(user2.id); + expect(result.map(u => u.id).sort()).toEqual([byName.id, byGiven.id, byUsername.id].sort()); }); - it('masks email when query does not include @', async () => { - const me = await buildUser({ email: 'current@example.com' }); - await buildUser({ email: 'user2@example.com' }); - currentUser = { id: me.id, email: me.email }; + it('returns the full email of a workspace member', async () => { + const { org } = await buildWorkspace(); + const colleague = await buildUser({ name: 'Ada Lovelace', email: 'ada@example.com' }); + await buildOrgMember({ orgId: org.id, user: colleague }); const result = await searchUsers(createDb(env.DB), mockSession(), dummyRequest, { - q: 'user', + q: 'ada', + orgId: org.id, }); - const u = result.find(x => x.email?.startsWith('us')); - expect(u).toBeDefined(); - expect(u!.email).toMatch(/^us\*\*\*@example\.com$/); + expect(result).toHaveLength(1); + expect(result[0].email).toBe('ada@example.com'); }); - it('returns full email when query includes @', async () => { - const me = await buildUser({ email: 'current@example.com' }); - const user2 = await buildUser({ email: 'user2@example.com' }); - currentUser = { id: me.id, email: me.email }; + it('never returns users outside the workspace, even by exact email', async () => { + const { org } = await buildWorkspace(); + await buildUser({ name: 'John Outsider', email: 'john@elsewhere.org' }); + + for (const q of ['john', 'john@elsewhere.org', '@elsewhere']) { + const result = await searchUsers(createDb(env.DB), mockSession(), dummyRequest, { + q, + orgId: org.id, + }); + expect(result).toEqual([]); + } + }); + + it('does not match on email inside the workspace', async () => { + const { org } = await buildWorkspace(); + const colleague = await buildUser({ name: 'Ada Lovelace', email: 'ada@example.com' }); + await buildOrgMember({ orgId: org.id, user: colleague }); const result = await searchUsers(createDb(env.DB), mockSession(), dummyRequest, { - q: 'user2@example.com', + q: '@example', + orgId: org.id, }); - expect(result).toHaveLength(1); - expect(result[0].email).toBe(user2.email); + expect(result).toEqual([]); + }); + + it('rejects a caller who is not in the workspace', async () => { + const { org } = await buildOrg(); + const stranger = await buildUser(); + currentUser = { id: stranger.id, email: stranger.email }; + + await expect( + searchUsers(createDb(env.DB), mockSession(), dummyRequest, { q: 'any', orgId: org.id }), + ).rejects.toMatchObject({ statusCode: 403 }); }); it('rejects query shorter than 2 characters', async () => { + const { org } = await buildWorkspace(); try { - await searchUsers(createDb(env.DB), mockSession(), dummyRequest, { q: 'a' }); + await searchUsers(createDb(env.DB), mockSession(), dummyRequest, { q: 'a', orgId: org.id }); expect.fail('Should have thrown'); } catch (err) { const res = err as DomainErrorException; expect(res.statusCode).toBe(400); - const body = res.toDomainError() as any; + const body = res.toDomainError() as { code: string; message: string }; expect(body.code).toMatch(/VALIDATION/); expect(body.message).toMatch(/2 characters|too short/i); } }); it('caps limit at 20', async () => { - const me = await buildUser({ email: 'current@example.com' }); + const { org } = await buildWorkspace(); for (let i = 0; i < 25; i++) { - await buildUser({ email: `searchuser${i}@example.com` }); + const user = await buildUser({ name: `Searchuser ${i}` }); + await buildOrgMember({ orgId: org.id, user }); } - currentUser = { id: me.id, email: me.email }; const result = await searchUsers(createDb(env.DB), mockSession(), dummyRequest, { q: 'searchuser', + orgId: org.id, limit: 100, }); expect(result.length).toBeLessThanOrEqual(20); }); - it('excludes current user', async () => { - const me = await buildUser({ name: 'Current User', email: 'user1@example.com' }); - const other = await buildUser({ name: 'Other User', email: 'user2@example.com' }); - currentUser = { id: me.id, email: me.email }; + it('excludes the current user', async () => { + const { org, owner } = await buildWorkspace(); + const other = await buildUser({ name: 'Other User' }); + await buildOrgMember({ orgId: org.id, user: other }); const result = await searchUsers(createDb(env.DB), mockSession(), dummyRequest, { q: 'user', + orgId: org.id, }); - expect(result.find(u => u.id === me.id)).toBeUndefined(); + expect(result.find(u => u.id === owner.id)).toBeUndefined(); expect(result.find(u => u.id === other.id)).toBeDefined(); }); @@ -113,62 +150,16 @@ describe('GET /api/users/search', () => { orgId: org.id, role: 'member', }); - const outsider = await buildUser({ email: 'user3@example.com' }); + const colleague = await buildUser({ name: 'User Three' }); + await buildOrgMember({ orgId: org.id, user: colleague }); currentUser = { id: owner.id, email: owner.email }; const result = await searchUsers(createDb(env.DB), mockSession(), dummyRequest, { q: 'user', + orgId: org.id, projectId: project.id, }); expect(result.find(u => u.id === projectMember.user.id)).toBeUndefined(); - expect(result.find(u => u.id === outsider.id)).toBeDefined(); - }); - - it('searches by name', async () => { - const me = await buildUser({ email: 'current@example.com' }); - const john = await buildUser({ name: 'John Doe', email: 'john@example.com' }); - currentUser = { id: me.id, email: me.email }; - - const result = await searchUsers(createDb(env.DB), mockSession(), dummyRequest, { - q: 'john', - }); - expect(result).toHaveLength(1); - expect(result[0].name).toBe(john.name); - }); - - it('searches by givenName', async () => { - const me = await buildUser({ email: 'current@example.com' }); - const johnny = await buildUser({ givenName: 'Johnny', email: 'user2@example.com' }); - currentUser = { id: me.id, email: me.email }; - - const result = await searchUsers(createDb(env.DB), mockSession(), dummyRequest, { - q: 'johnny', - }); - expect(result).toHaveLength(1); - expect(result[0].givenName).toBe(johnny.givenName); - }); - - it('searches by username', async () => { - const me = await buildUser({ email: 'current@example.com' }); - const johndoe = await buildUser({ username: 'johndoe', email: 'user2@example.com' }); - currentUser = { id: me.id, email: me.email }; - - const result = await searchUsers(createDb(env.DB), mockSession(), dummyRequest, { - q: 'johndoe', - }); - expect(result).toHaveLength(1); - expect(result[0].username).toBe(johndoe.username); - }); - - it('is case-insensitive', async () => { - const me = await buildUser({ email: 'current@example.com' }); - const john = await buildUser({ name: 'John Doe', email: 'john@example.com' }); - currentUser = { id: me.id, email: me.email }; - - const result = await searchUsers(createDb(env.DB), mockSession(), dummyRequest, { - q: 'JOHN', - }); - expect(result).toHaveLength(1); - expect(result[0].name).toBe(john.name); + expect(result.find(u => u.id === colleague.id)).toBeDefined(); }); }); diff --git a/packages/web/src/server/functions/users.functions.ts b/packages/web/src/server/functions/users.functions.ts index c3451bb94..ef7673816 100644 --- a/packages/web/src/server/functions/users.functions.ts +++ b/packages/web/src/server/functions/users.functions.ts @@ -16,6 +16,7 @@ export const searchUsers = createServerFn({ method: 'GET' }) .validator( z.object({ q: z.string(), + orgId: z.string(), projectId: z.string().optional(), limit: z.number().optional(), }), diff --git a/packages/web/src/server/functions/users.server.ts b/packages/web/src/server/functions/users.server.ts index ebc7467bb..d98c0029b 100644 --- a/packages/web/src/server/functions/users.server.ts +++ b/packages/web/src/server/functions/users.server.ts @@ -1,6 +1,6 @@ import type { Database } from '@corates/db/client'; -import { projects, projectMembers, user } from '@corates/db/schema'; -import { eq, or, desc, count } from 'drizzle-orm'; +import { projects, projectMembers, user, member } from '@corates/db/schema'; +import { eq, and, or, desc, count } from 'drizzle-orm'; import { alias } from 'drizzle-orm/sqlite-core'; import { containsInsensitive } from '@/server/lib/sqlSearch'; import { deleteUserAccount } from '@/server/lib/accountDeletion'; @@ -12,6 +12,8 @@ import { } from '@corates/shared'; import type { Session } from '@/server/middleware/auth'; +import type { OrgId } from '@corates/shared/ids'; +import { requireOrgMembership } from '@/server/guards/requireOrgMembership'; export interface UserProject { id: string; @@ -37,14 +39,6 @@ export interface UserSearchResult { email: string | null; } -function maskEmail(email: string | null): string | null { - if (!email) return null; - const [local, domain] = email.split('@'); - if (!domain) return email; - const masked = local.length > 2 ? local.slice(0, 2) + '***' : local + '***'; - return `${masked}@${domain}`; -} - export async function deleteAccount(db: Database, session: Session) { await deleteUserAccount(db, { userId: session.user.id, email: session.user.email }); return { success: true as const, message: 'Account deleted successfully' }; @@ -81,7 +75,7 @@ export async function searchUsers( db: Database, session: Session, _request: Request, - params: { q: string; projectId?: string; limit?: number }, + params: { q: string; orgId: string; projectId?: string; limit?: number }, ) { if (!params.q || params.q.length < 2) { const error = createValidationError('q', VALIDATION_ERRORS.FIELD_TOO_SHORT.code, params.q); @@ -89,6 +83,12 @@ export async function searchUsers( throw new DomainErrorException(error); } + // Only people already in the workspace are searchable; anyone else is + // invited by typing their address, so the user table is never enumerable + // across workspaces. + const orgMembership = await requireOrgMembership(session, db, params.orgId as OrgId); + if (!orgMembership.ok) throw orgMembership.error; + const limit = Math.min(params.limit && Number.isFinite(params.limit) ? params.limit : 10, 20); let results = await db @@ -102,13 +102,16 @@ export async function searchUsers( image: user.image, }) .from(user) + .innerJoin(member, eq(member.userId, user.id)) .where( - or( - containsInsensitive(user.email, params.q), - containsInsensitive(user.name, params.q), - containsInsensitive(user.givenName, params.q), - containsInsensitive(user.familyName, params.q), - containsInsensitive(user.username, params.q), + and( + eq(member.organizationId, params.orgId), + or( + containsInsensitive(user.name, params.q), + containsInsensitive(user.givenName, params.q), + containsInsensitive(user.familyName, params.q), + containsInsensitive(user.username, params.q), + ), ), ) .limit(limit); @@ -131,7 +134,7 @@ export async function searchUsers( familyName: u.familyName, username: u.username, image: u.image, - email: params.q.includes('@') ? u.email : maskEmail(u.email), + email: u.email, })); return sanitized; From 8f7ea6abe5d97f7ca6b4dc2863a2b27d6614aab9 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 12 Sep 2026 19:35:39 +0000 Subject: [PATCH 3/5] Apply Prettier formatting --- .../web/migrations/meta/0012_snapshot.json | 301 +++++------------- packages/web/migrations/meta/_journal.json | 2 +- 2 files changed, 75 insertions(+), 228 deletions(-) diff --git a/packages/web/migrations/meta/0012_snapshot.json b/packages/web/migrations/meta/0012_snapshot.json index 0961c9fec..559fb26da 100644 --- a/packages/web/migrations/meta/0012_snapshot.json +++ b/packages/web/migrations/meta/0012_snapshot.json @@ -111,17 +111,12 @@ "indexes": { "account_userId_idx": { "name": "account_userId_idx", - "columns": [ - "userId" - ], + "columns": ["userId"], "isUnique": false }, "account_issuer_accountId_uidx": { "name": "account_issuer_accountId_uidx", - "columns": [ - "issuer", - "accountId" - ], + "columns": ["issuer", "accountId"], "isUnique": true } }, @@ -130,12 +125,8 @@ "name": "account_userId_user_id_fk", "tableFrom": "account", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -202,17 +193,12 @@ "indexes": { "contact_submissions_dedupKey_unique": { "name": "contact_submissions_dedupKey_unique", - "columns": [ - "dedupKey" - ], + "columns": ["dedupKey"], "isUnique": true }, "contact_submissions_email_createdAt_idx": { "name": "contact_submissions_email_createdAt_idx", - "columns": [ - "email", - "createdAt" - ], + "columns": ["email", "createdAt"], "isUnique": false } }, @@ -279,10 +265,7 @@ "indexes": { "feedback_userId_createdAt_idx": { "name": "feedback_userId_createdAt_idx", - "columns": [ - "userId", - "createdAt" - ], + "columns": ["userId", "createdAt"], "isUnique": false } }, @@ -291,12 +274,8 @@ "name": "feedback_userId_user_id_fk", "tableFrom": "feedback", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -374,12 +353,8 @@ "name": "invitation_inviterId_user_id_fk", "tableFrom": "invitation", "tableTo": "user", - "columnsFrom": [ - "inviterId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["inviterId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -387,12 +362,8 @@ "name": "invitation_organizationId_organization_id_fk", "tableFrom": "invitation", "tableTo": "organization", - "columnsFrom": [ - "organizationId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -486,9 +457,7 @@ "indexes": { "mediaFiles_projectId_idx": { "name": "mediaFiles_projectId_idx", - "columns": [ - "projectId" - ], + "columns": ["projectId"], "isUnique": false } }, @@ -497,12 +466,8 @@ "name": "mediaFiles_uploadedBy_user_id_fk", "tableFrom": "mediaFiles", "tableTo": "user", - "columnsFrom": [ - "uploadedBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["uploadedBy"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -510,12 +475,8 @@ "name": "mediaFiles_orgId_organization_id_fk", "tableFrom": "mediaFiles", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -523,12 +484,8 @@ "name": "mediaFiles_projectId_projects_id_fk", "tableFrom": "mediaFiles", "tableTo": "projects", - "columnsFrom": [ - "projectId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["projectId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -581,16 +538,12 @@ "indexes": { "member_userId_idx": { "name": "member_userId_idx", - "columns": [ - "userId" - ], + "columns": ["userId"], "isUnique": false }, "member_organizationId_idx": { "name": "member_organizationId_idx", - "columns": [ - "organizationId" - ], + "columns": ["organizationId"], "isUnique": false } }, @@ -599,12 +552,8 @@ "name": "member_userId_user_id_fk", "tableFrom": "member", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -612,12 +561,8 @@ "name": "member_organizationId_organization_id_fk", "tableFrom": "member", "tableTo": "organization", - "columnsFrom": [ - "organizationId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -675,18 +620,12 @@ "indexes": { "notifications_userId_createdAt_idx": { "name": "notifications_userId_createdAt_idx", - "columns": [ - "userId", - "createdAt" - ], + "columns": ["userId", "createdAt"], "isUnique": false }, "notifications_userId_readAt_idx": { "name": "notifications_userId_readAt_idx", - "columns": [ - "userId", - "readAt" - ], + "columns": ["userId", "readAt"], "isUnique": false } }, @@ -695,12 +634,8 @@ "name": "notifications_userId_user_id_fk", "tableFrom": "notifications", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -780,16 +715,12 @@ "indexes": { "org_access_grants_stripeCheckoutSessionId_unique": { "name": "org_access_grants_stripeCheckoutSessionId_unique", - "columns": [ - "stripeCheckoutSessionId" - ], + "columns": ["stripeCheckoutSessionId"], "isUnique": true }, "org_access_grants_orgId_idx": { "name": "org_access_grants_orgId_idx", - "columns": [ - "orgId" - ], + "columns": ["orgId"], "isUnique": false } }, @@ -798,12 +729,8 @@ "name": "org_access_grants_orgId_organization_id_fk", "tableFrom": "org_access_grants", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -862,9 +789,7 @@ "indexes": { "organization_slug_unique": { "name": "organization_slug_unique", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -1007,24 +932,17 @@ "indexes": { "project_invitations_token_unique": { "name": "project_invitations_token_unique", - "columns": [ - "token" - ], + "columns": ["token"], "isUnique": true }, "project_invitations_projectId_idx": { "name": "project_invitations_projectId_idx", - "columns": [ - "projectId" - ], + "columns": ["projectId"], "isUnique": false }, "project_invitations_projectId_email_uidx": { "name": "project_invitations_projectId_email_uidx", - "columns": [ - "projectId", - "email" - ], + "columns": ["projectId", "email"], "isUnique": true } }, @@ -1033,12 +951,8 @@ "name": "project_invitations_orgId_organization_id_fk", "tableFrom": "project_invitations", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1046,12 +960,8 @@ "name": "project_invitations_projectId_projects_id_fk", "tableFrom": "project_invitations", "tableTo": "projects", - "columnsFrom": [ - "projectId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["projectId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1059,12 +969,8 @@ "name": "project_invitations_invitedBy_user_id_fk", "tableFrom": "project_invitations", "tableTo": "user", - "columnsFrom": [ - "invitedBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["invitedBy"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1117,24 +1023,17 @@ "indexes": { "project_members_projectId_idx": { "name": "project_members_projectId_idx", - "columns": [ - "projectId" - ], + "columns": ["projectId"], "isUnique": false }, "project_members_userId_idx": { "name": "project_members_userId_idx", - "columns": [ - "userId" - ], + "columns": ["userId"], "isUnique": false }, "project_members_projectId_userId_uidx": { "name": "project_members_projectId_userId_uidx", - "columns": [ - "projectId", - "userId" - ], + "columns": ["projectId", "userId"], "isUnique": true } }, @@ -1143,12 +1042,8 @@ "name": "project_members_projectId_projects_id_fk", "tableFrom": "project_members", "tableTo": "projects", - "columnsFrom": [ - "projectId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["projectId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1156,12 +1051,8 @@ "name": "project_members_userId_user_id_fk", "tableFrom": "project_members", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1228,9 +1119,7 @@ "indexes": { "projects_orgId_idx": { "name": "projects_orgId_idx", - "columns": [ - "orgId" - ], + "columns": ["orgId"], "isUnique": false } }, @@ -1239,12 +1128,8 @@ "name": "projects_orgId_organization_id_fk", "tableFrom": "projects", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1252,12 +1137,8 @@ "name": "projects_createdBy_user_id_fk", "tableFrom": "projects", "tableTo": "user", - "columnsFrom": [ - "createdBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["createdBy"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1301,9 +1182,7 @@ "indexes": { "rateLimit_key_unique": { "name": "rateLimit_key_unique", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true } }, @@ -1391,16 +1270,12 @@ "indexes": { "session_token_unique": { "name": "session_token_unique", - "columns": [ - "token" - ], + "columns": ["token"], "isUnique": true }, "session_userId_idx": { "name": "session_userId_idx", - "columns": [ - "userId" - ], + "columns": ["userId"], "isUnique": false } }, @@ -1409,12 +1284,8 @@ "name": "session_userId_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1422,12 +1293,8 @@ "name": "session_impersonatedBy_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "impersonatedBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["impersonatedBy"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -1435,12 +1302,8 @@ "name": "session_activeOrganizationId_organization_id_fk", "tableFrom": "session", "tableTo": "organization", - "columnsFrom": [ - "activeOrganizationId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["activeOrganizationId"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1590,16 +1453,12 @@ "indexes": { "stripe_event_ledger_payloadHash_unique": { "name": "stripe_event_ledger_payloadHash_unique", - "columns": [ - "payloadHash" - ], + "columns": ["payloadHash"], "isUnique": true }, "stripe_event_ledger_stripeEventId_unique": { "name": "stripe_event_ledger_stripeEventId_unique", - "columns": [ - "stripeEventId" - ], + "columns": ["stripeEventId"], "isUnique": true } }, @@ -1738,16 +1597,12 @@ "indexes": { "subscription_referenceId_idx": { "name": "subscription_referenceId_idx", - "columns": [ - "referenceId" - ], + "columns": ["referenceId"], "isUnique": false }, "subscription_referenceId_incomplete_uidx": { "name": "subscription_referenceId_incomplete_uidx", - "columns": [ - "referenceId" - ], + "columns": ["referenceId"], "isUnique": true, "where": "\"subscription\".\"status\" = 'incomplete'" } @@ -1811,12 +1666,8 @@ "name": "twoFactor_userId_user_id_fk", "tableFrom": "twoFactor", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2033,16 +1884,12 @@ "indexes": { "user_email_unique": { "name": "user_email_unique", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true }, "user_username_unique": { "name": "user_username_unique", - "columns": [ - "username" - ], + "columns": ["username"], "isUnique": true } }, @@ -2116,4 +1963,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/packages/web/migrations/meta/_journal.json b/packages/web/migrations/meta/_journal.json index c3fe83a17..82a27c2e4 100644 --- a/packages/web/migrations/meta/_journal.json +++ b/packages/web/migrations/meta/_journal.json @@ -94,4 +94,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} From b9aefec79ca27ad248d02ed935f241e154b6fa48 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 12 Sep 2026 14:36:48 -0500 Subject: [PATCH 4/5] Match workspace members by email too The member join is what stops cross-workspace enumeration; inside the workspace, colleagues already see each other's addresses, so there is no reason to hide a match on email. This also restores the account row with name and avatar when an inviter types a colleague's address. Claude-Session: https://claude.ai/code/session_014whY28tH1iAuRFLhWA4c5p --- .../server/functions/__tests__/users-search.server.test.ts | 6 +++--- packages/web/src/server/functions/users.server.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/web/src/server/functions/__tests__/users-search.server.test.ts b/packages/web/src/server/functions/__tests__/users-search.server.test.ts index d5ca27d18..ea376e9b0 100644 --- a/packages/web/src/server/functions/__tests__/users-search.server.test.ts +++ b/packages/web/src/server/functions/__tests__/users-search.server.test.ts @@ -79,16 +79,16 @@ describe('searchUsers', () => { } }); - it('does not match on email inside the workspace', async () => { + it('matches a workspace member by email', async () => { const { org } = await buildWorkspace(); const colleague = await buildUser({ name: 'Ada Lovelace', email: 'ada@example.com' }); await buildOrgMember({ orgId: org.id, user: colleague }); const result = await searchUsers(createDb(env.DB), mockSession(), dummyRequest, { - q: '@example', + q: 'ada@example.com', orgId: org.id, }); - expect(result).toEqual([]); + expect(result.map(u => u.id)).toEqual([colleague.id]); }); it('rejects a caller who is not in the workspace', async () => { diff --git a/packages/web/src/server/functions/users.server.ts b/packages/web/src/server/functions/users.server.ts index d98c0029b..ecf19ae52 100644 --- a/packages/web/src/server/functions/users.server.ts +++ b/packages/web/src/server/functions/users.server.ts @@ -83,9 +83,8 @@ export async function searchUsers( throw new DomainErrorException(error); } - // Only people already in the workspace are searchable; anyone else is - // invited by typing their address, so the user table is never enumerable - // across workspaces. + // Only people already in the workspace are searchable, so the user table is + // never enumerable across workspaces. Anyone else is invited by address. const orgMembership = await requireOrgMembership(session, db, params.orgId as OrgId); if (!orgMembership.ok) throw orgMembership.error; @@ -107,6 +106,7 @@ export async function searchUsers( and( eq(member.organizationId, params.orgId), or( + containsInsensitive(user.email, params.q), containsInsensitive(user.name, params.q), containsInsensitive(user.givenName, params.q), containsInsensitive(user.familyName, params.q), From 8667effdab1174bd7326e7d3d4cdaebd8050bf3c Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 12 Sep 2026 14:39:41 -0500 Subject: [PATCH 5/5] Shorten the not-sent toast to fit its three-second display Claude-Session: https://claude.ai/code/session_014whY28tH1iAuRFLhWA4c5p --- .../web/src/components/project/overview-tab/AddMemberModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/components/project/overview-tab/AddMemberModal.tsx b/packages/web/src/components/project/overview-tab/AddMemberModal.tsx index 0a5449394..b7070f439 100644 --- a/packages/web/src/components/project/overview-tab/AddMemberModal.tsx +++ b/packages/web/src/components/project/overview-tab/AddMemberModal.tsx @@ -220,7 +220,7 @@ export function AddMemberModal({ } else { showToast.warning( 'Invitation created, but no email was sent', - `${sentTo} can still accept from their CoRATES notifications. Invite them again later to retry the email.`, + `${sentTo} can still accept it from their notifications in CoRATES.`, ); } // The invitations list is a D1 fact read through React Query. Nothing