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..559fb26da --- /dev/null +++ b/packages/web/migrations/meta/0012_snapshot.json @@ -0,0 +1,1966 @@ +{ + "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": {} + } +} diff --git a/packages/web/migrations/meta/_journal.json b/packages/web/migrations/meta/_journal.json index 1d8c24f07..82a27c2e4 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 } ] } 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/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 6e90f7352..b7070f439 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'; @@ -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. @@ -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 it from their notifications in CoRATES.`, + ); + } // 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/__tests__/users-search.server.test.ts b/packages/web/src/server/functions/__tests__/users-search.server.test.ts index d13a73498..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 @@ -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('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: 'user2@example.com', + q: 'ada@example.com', + orgId: org.id, }); - expect(result).toHaveLength(1); - expect(result[0].email).toBe(user2.email); + expect(result.map(u => u.id)).toEqual([colleague.id]); + }); + + 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/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/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..ecf19ae52 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,11 @@ export async function searchUsers( throw new DomainErrorException(error); } + // 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; + const limit = Math.min(params.limit && Number.isFinite(params.limit) ? params.limit : 10, 20); let results = await db @@ -102,13 +101,17 @@ 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.email, params.q), + 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; 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(); } }