diff --git a/README.md b/README.md index 14023ab..5c37e15 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,13 @@ const resent = await lettermint.messages.resend('message-id', { recipients: ['ja // Send a webhook delivery's original payload again now; automatic retries continue if it fails. await lettermint.webhooks.deliveries.retry('webhook-id', 'delivery-id'); + +// Invite a person to the team; the API sends the invitation email. +const invite = await lettermint.team.invites.create({ + email: 'jane@example.com', + role_id: 'role-id', + project_access: { scope: 'selected', project_ids: [project.id] }, +}); ``` | Property | Methods | @@ -272,6 +279,7 @@ await lettermint.webhooks.deliveries.retry('webhook-id', 'delivery-id'); | `suppressions` | `list`, `iterate`, `create`, `delete` | | `team` | `retrieve`, `update`, `usage`, `roles` | | `team.members` | `list`, `iterate`, `retrieve`, `updateAssignment` | +| `team.invites` | `list`, `iterate`, `retrieve`, `create`, `update`, `revoke` | | `webhooks` | `list`, `iterate`, `create`, `retrieve`, `update`, `delete`, `test`, `regenerateSecret` | | `webhooks.deliveries` | `list(webhookId)`, `iterate(webhookId)`, `retrieve(webhookId, deliveryId)`, `retry(webhookId, deliveryId)` | | (root) | `ping`, `analytics`, `analyticsPages`, `blockedFileTypes` | diff --git a/src/generated/operations.ts b/src/generated/operations.ts index 6871066..643dd3e 100644 --- a/src/generated/operations.ts +++ b/src/generated/operations.ts @@ -1,7 +1,7 @@ // Generated by lettermint/sdk-generator — do not edit. -// Spec: lettermint/lettermint@6ef4036dede6326ad2200b945e689e319faf4c0a (main) +// Spec: lettermint/lettermint@a14a098fd3173b67bc366163127ee3715457cd59 (main) // sending-openapi.json SHA-256: 8d5e962e57d79ab08be52147c094657b681768f06940a76c8eec1b1e03df54cc -// team-openapi.json SHA-256: 4ba5b65756faa9db79e5b95e06414899af0a6e97766eed04656c1b778f28aef7 +// team-openapi.json SHA-256: da4398ead370c9b61eadb4007087a6da945df3c609801325fd38fc780cc0e707 // Naming profile: next import type * as Types from './types.js'; @@ -136,6 +136,72 @@ export const operations = { pagination: null, deprecated: false, }, + /** Revoke a pending team invite */ + 'DELETE /team/invites/{inviteId}': { + operationId: 'deleteTeamInvite', + method: 'DELETE', + path: '/team/invites/{inviteId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['inviteId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'MessageResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Remove a team member */ + 'DELETE /team/members/{userId}': { + operationId: 'deleteTeamMember', + method: 'DELETE', + path: '/team/members/{userId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['userId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'MessageResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 409: ['RbacConflictException'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Delete a custom team role */ + 'DELETE /team/roles/{roleId}': { + operationId: 'deleteTeamRole', + method: 'DELETE', + path: '/team/roles/{roleId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['roleId'], + query: 'DeleteTeamRoleQuery', + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'MessageResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 409: ['RbacConflictException'], + 422: ['ValidationException'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, /** Delete a webhook */ 'DELETE /webhooks/{webhookId}': { operationId: 'deleteWebhook', @@ -530,6 +596,47 @@ export const operations = { pagination: null, deprecated: false, }, + /** Get team invites */ + 'GET /team/invites': { + operationId: 'listTeamInvites', + method: 'GET', + path: '/team/invites', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: 'ListTeamInvitesQuery', + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'ListTeamInvitesResponse' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 429: ['TooManyRequests'], + }, + pagination: { style: 'cursor', items: 'TeamInviteData', cursorParam: 'page[cursor]', sizeParam: 'page[size]' }, + deprecated: false, + }, + /** Get a team invite */ + 'GET /team/invites/{inviteId}': { + operationId: 'getTeamInvite', + method: 'GET', + path: '/team/invites/{inviteId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['inviteId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'TeamInviteData' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, /** Get team members */ 'GET /team/members': { operationId: 'listTeamMembers', @@ -593,6 +700,27 @@ export const operations = { pagination: null, deprecated: false, }, + /** Get a team role */ + 'GET /team/roles/{roleId}': { + operationId: 'getTeamRole', + method: 'GET', + path: '/team/roles/{roleId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['roleId'], + query: null, + headers: [], + request: null, + response: { status: [200], contentType: 'application/json', type: 'TeamRoleData' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, /** Get team usage statistics */ 'GET /team/usage': { operationId: 'getTeamUsage', @@ -1079,6 +1207,74 @@ export const operations = { pagination: null, deprecated: false, }, + /** Invite a person to the team */ + 'POST /team/invites': { + operationId: 'createTeamInvite', + method: 'POST', + path: '/team/invites', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: null, + headers: [], + request: { type: 'CreateTeamInviteData', contentType: 'application/json', required: true }, + response: { status: [201], contentType: 'application/json', type: 'TeamInviteData' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ModelNotFoundException'], + 409: ['RbacConflictException'], + 422: ['ValidationException'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Create a custom team role */ + 'POST /team/roles': { + operationId: 'createTeamRole', + method: 'POST', + path: '/team/roles', + auth: 'team', + surfaces: ['team'], + pathParams: [], + query: null, + headers: [], + request: { type: 'CreateTeamRoleData', contentType: 'application/json', required: true }, + response: { status: [201], contentType: 'application/json', type: 'TeamRoleData' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 409: ['RbacConflictException'], + 422: ['ValidationException'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, + /** Duplicate a team role */ + 'POST /team/roles/{roleId}/duplicate': { + operationId: 'duplicateTeamRole', + method: 'POST', + path: '/team/roles/{roleId}/duplicate', + auth: 'team', + surfaces: ['team'], + pathParams: ['roleId'], + query: null, + headers: [], + request: { type: 'DuplicateTeamRoleData', contentType: 'application/json', required: true }, + response: { status: [201], contentType: 'application/json', type: 'TeamRoleData' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 409: ['RbacConflictException'], + 422: ['ValidationException'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, /** Create a new webhook */ 'POST /webhooks': { operationId: 'createWebhook', @@ -1273,6 +1469,29 @@ export const operations = { pagination: null, deprecated: false, }, + /** Replace a pending team invite */ + 'PUT /team/invites/{inviteId}': { + operationId: 'updateTeamInvite', + method: 'PUT', + path: '/team/invites/{inviteId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['inviteId'], + query: null, + headers: [], + request: { type: 'UpdateTeamInviteData', contentType: 'application/json', required: true }, + response: { status: [200], contentType: 'application/json', type: 'TeamInviteData' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 409: ['RbacConflictException'], + 422: ['ValidationException'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, /** Replace a team member's assignment */ 'PUT /team/members/{userId}/assignment': { operationId: 'updateTeamMemberAssignment', @@ -1296,6 +1515,29 @@ export const operations = { pagination: null, deprecated: false, }, + /** Replace a custom team role */ + 'PUT /team/roles/{roleId}': { + operationId: 'updateTeamRole', + method: 'PUT', + path: '/team/roles/{roleId}', + auth: 'team', + surfaces: ['team'], + pathParams: ['roleId'], + query: null, + headers: [], + request: { type: 'UpdateTeamRoleData', contentType: 'application/json', required: true }, + response: { status: [200], contentType: 'application/json', type: 'TeamRoleData' }, + errors: { + 401: ['ApiError'], + 403: ['ApiError'], + 404: ['ErrorMessage', 'ApiError'], + 409: ['RbacConflictException'], + 422: ['ValidationException'], + 429: ['TooManyRequests'], + }, + pagination: null, + deprecated: false, + }, /** Update webhook settings */ 'PUT /webhooks/{webhookId}': { operationId: 'updateWebhook', @@ -1326,7 +1568,10 @@ export interface OperationTypes { AnalyticsResponse: Types.AnalyticsResponse; ApiError: Types.ApiError; BlockedFileTypes: Types.BlockedFileTypes; + CreateTeamInviteData: Types.CreateTeamInviteData; + CreateTeamRoleData: Types.CreateTeamRoleData; DeleteSuppressionResponse: Types.DeleteSuppressionResponse; + DeleteTeamRoleQuery: Types.DeleteTeamRoleQuery; DeliveryMode: Types.DeliveryMode; DnsRecordVerificationFailureResponse: Types.DnsRecordVerificationFailureResponse; DnsVerificationFailureResponse: Types.DnsVerificationFailureResponse; @@ -1335,6 +1580,7 @@ export interface OperationTypes { DomainListData: Types.DomainListData; DomainMutationResponse: Types.DomainMutationResponse; DomainStatus: Types.DomainStatus; + DuplicateTeamRoleData: Types.DuplicateTeamRoleData; ErrorMessage: Types.ErrorMessage; GetDomainQuery: Types.GetDomainQuery; GetDomainQueryIncludeItem: Types.GetDomainQueryIncludeItem; @@ -1366,6 +1612,8 @@ export interface OperationTypes { ListSuppressionsQuery: Types.ListSuppressionsQuery; ListSuppressionsQuerySortItem: Types.ListSuppressionsQuerySortItem; ListSuppressionsResponse: Types.ListSuppressionsResponse; + ListTeamInvitesQuery: Types.ListTeamInvitesQuery; + ListTeamInvitesResponse: Types.ListTeamInvitesResponse; ListTeamMembersQuery: Types.ListTeamMembersQuery; ListTeamMembersResponse: Types.ListTeamMembersResponse; ListWebhookDeliveriesQuery: Types.ListWebhookDeliveriesQuery; @@ -1414,8 +1662,10 @@ export interface OperationTypes { SuppressionReason: Types.SuppressionReason; SuppressionStoreResponse: Types.SuppressionStoreResponse; TeamData: Types.TeamData; + TeamInviteData: Types.TeamInviteData; TeamMemberData: Types.TeamMemberData; TeamMutationResponse: Types.TeamMutationResponse; + TeamRoleData: Types.TeamRoleData; TeamRoleListResponse: Types.TeamRoleListResponse; TeamUsageDetailData: Types.TeamUsageDetailData; TestWebhookResponse: Types.TestWebhookResponse; @@ -1425,7 +1675,9 @@ export interface OperationTypes { UpdateReportForwardingResponse: Types.UpdateReportForwardingResponse; UpdateRouteData: Types.UpdateRouteData; UpdateTeamData: Types.UpdateTeamData; + UpdateTeamInviteData: Types.UpdateTeamInviteData; UpdateTeamMemberAssignmentData: Types.UpdateTeamMemberAssignmentData; + UpdateTeamRoleData: Types.UpdateTeamRoleData; UpdateWebhookData: Types.UpdateWebhookData; ValidationError: Types.ValidationError; ValidationException: Types.ValidationException; diff --git a/src/generated/types.ts b/src/generated/types.ts index b0f179b..44c0412 100644 --- a/src/generated/types.ts +++ b/src/generated/types.ts @@ -1,7 +1,7 @@ // Generated by lettermint/sdk-generator — do not edit. -// Spec: lettermint/lettermint@6ef4036dede6326ad2200b945e689e319faf4c0a (main) +// Spec: lettermint/lettermint@a14a098fd3173b67bc366163127ee3715457cd59 (main) // sending-openapi.json SHA-256: 8d5e962e57d79ab08be52147c094657b681768f06940a76c8eec1b1e03df54cc -// team-openapi.json SHA-256: 4ba5b65756faa9db79e5b95e06414899af0a6e97766eed04656c1b778f28aef7 +// team-openapi.json SHA-256: da4398ead370c9b61eadb4007087a6da945df3c609801325fd38fc780cc0e707 // Naming profile: next /** Metric values for one group of dimension values. */ @@ -417,6 +417,23 @@ export interface BlockedFileTypes { export type BuiltInTeamRole = 'owner' | 'admin' | 'member' | (string & {}); +export interface CreateTeamInviteData { + email: string; + role_id: string; + project_access: CreateTeamInviteDataProjectAccess; +} + +export interface CreateTeamInviteDataProjectAccess { + scope: ProjectAccessScope; + project_ids?: string[]; +} + +export interface CreateTeamRoleData { + name: string; + description?: string | null; + permissions: RbacPermission[]; +} + /** A page of a cursor-paginated list. Pass `next_cursor` back as the cursor query parameter to read the next page; it is `null` on the last page. */ export interface CursorPage { data: T[]; @@ -434,6 +451,12 @@ export interface CursorPage { export type DeleteSuppressionResponse = SuppressionDeletedResponse | SuppressionReviewResponse; +/** Query parameters of `DELETE /team/roles/{roleId}` (deleteTeamRole). */ +export interface DeleteTeamRoleQuery { + /** Same-team non-Owner role that receives existing assignments. Required only while the deleted role is assigned. */ + replacement_role_id?: string | null; +} + export type DeliveryMode = 'live' | 'sandbox' | (string & {}); export type DkimMode = 'legacy_txt' | 'managed_cname' | (string & {}); @@ -586,6 +609,11 @@ export type DomainStatus = | 'failed_verification' | (string & {}); +export interface DuplicateTeamRoleData { + name: string; + description?: string | null; +} + /** Error with only a message. */ export interface ErrorMessage { /** Error overview. */ @@ -853,12 +881,24 @@ export type ListSuppressionsQuerySortItem = export type ListSuppressionsResponse = CursorPage; +/** Query parameters of `GET /team/invites` (listTeamInvites). */ +export interface ListTeamInvitesQuery { + /** The number of results that will be returned per page. */ + 'page[size]'?: number; + /** The cursor to start the pagination from. */ + 'page[cursor]'?: string; +} + +export type ListTeamInvitesResponse = CursorPage; + /** Query parameters of `GET /team/members` (listTeamMembers). */ export interface ListTeamMembersQuery { /** The number of results that will be returned per page. */ 'page[size]'?: number; /** The cursor to start the pagination from. */ 'page[cursor]'?: string; + /** Only members who can access this project: members with access to all projects and members selected for it. An unknown project returns an empty list. */ + 'filter[project_id]'?: string; } export type ListTeamMembersResponse = CursorPage; @@ -1710,6 +1750,19 @@ export interface TeamData { members_count?: number; } +export interface TeamInviteData { + id: string; + email: string; + role: TeamInviteDataRole; + project_access: TeamMemberProjectAccessData; + created_at: string; +} + +export interface TeamInviteDataRole { + id: string; + name: string; +} + export interface TeamMemberData { id: string; name: string; @@ -1744,6 +1797,7 @@ export interface TeamMutationResponse { export interface TeamRoleData { id: string; name: string; + description?: string | null; system_key: BuiltInTeamRole | null; permissions: RbacPermission[]; assignable: boolean; @@ -1840,6 +1894,17 @@ export interface UpdateTeamData { name?: string; } +export interface UpdateTeamInviteData { + email: string; + role_id: string; + project_access: UpdateTeamInviteDataProjectAccess; +} + +export interface UpdateTeamInviteDataProjectAccess { + scope: ProjectAccessScope; + project_ids?: string[]; +} + export interface UpdateTeamMemberAssignmentData { role_id: string; project_access: UpdateTeamMemberAssignmentDataProjectAccess; @@ -1850,6 +1915,12 @@ export interface UpdateTeamMemberAssignmentDataProjectAccess { project_ids?: string[]; } +export interface UpdateTeamRoleData { + name: string; + description?: string | null; + permissions: RbacPermission[]; +} + export interface UpdateWebhookData { name?: string; url?: string; diff --git a/src/index.ts b/src/index.ts index 09207fd..c87b9c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,7 @@ export type { Domains } from './resources/domains'; export type { Messages } from './resources/messages'; export type { Projects, ReportForwarding } from './resources/projects'; export type { Routes } from './resources/routes'; -export type { Stats, Suppressions, Team, TeamMembers } from './resources/team'; +export type { Stats, Suppressions, Team, TeamInvites, TeamMembers } from './resources/team'; export type { WebhookDeliveries, Webhooks } from './resources/webhooks'; export type { FetchFunction, IdempotentRequestOptions, RequestOptions } from './transport'; export type { NestedQuery } from './query'; diff --git a/src/lettermint.ts b/src/lettermint.ts index e0b2fc1..289958f 100644 --- a/src/lettermint.ts +++ b/src/lettermint.ts @@ -68,7 +68,7 @@ export class Lettermint { public readonly stats: Stats; /** The suppression list. Needs `teamToken`. */ public readonly suppressions: Suppressions; - /** The team and its members. Needs `teamToken`. */ + /** The team, its members and its invitations. Needs `teamToken`. */ public readonly team: Team; /** Webhook endpoints and their deliveries. Needs `teamToken`. */ public readonly webhooks: Webhooks; diff --git a/src/resources/team.ts b/src/resources/team.ts index ab3402a..bf92cc7 100644 --- a/src/resources/team.ts +++ b/src/resources/team.ts @@ -1,21 +1,27 @@ import type { + CreateTeamInviteData, DeleteSuppressionResponse, GetStatsQuery, GetTeamQuery, ListSuppressionsQuery, ListSuppressionsResponse, + ListTeamInvitesQuery, + ListTeamInvitesResponse, ListTeamMembersQuery, ListTeamMembersResponse, + MessageResponse, StatsData, StoreSuppressionData, SuppressedRecipientData, SuppressionStoreResponse, TeamData, + TeamInviteData, TeamMemberData, TeamMutationResponse, TeamRoleListResponse, TeamUsageDetailData, UpdateTeamData, + UpdateTeamInviteData, UpdateTeamMemberAssignmentData, } from '../generated/types'; import type { NestedQuery } from '../query'; @@ -117,15 +123,85 @@ export class TeamMembers extends ApiResource { } } +/** Pending team invitations. Team token. */ +export class TeamInvites extends ApiResource { + /** @internal */ + constructor(transport: Transport) { + super(transport, 'TeamInvites'); + } + + /** Lists pending invitations, one page at a time. */ + list( + query?: NestedQuery, + options?: RequestOptions + ): Promise { + return this.request('GET /team/invites', { label: 'team.invites.list', query, options }); + } + + /** Iterates over every pending invitation, following `next_cursor`. */ + iterate( + query?: NestedQuery, + options?: RequestOptions + ): AsyncGenerator { + return this.paginate('GET /team/invites', { label: 'team.invites.iterate', query, options }); + } + + retrieve(inviteId: string, options?: RequestOptions): Promise { + return this.request('GET /team/invites/{inviteId}', { + label: 'team.invites.retrieve', + path: { inviteId }, + options, + }); + } + + /** + * Invites a person by email with a role and project access, and sends the + * invitation email. The team needs a free seat; the Owner role cannot be + * assigned. + */ + create(body: CreateTeamInviteData, options?: RequestOptions): Promise { + return this.request('POST /team/invites', { label: 'team.invites.create', body, options }); + } + + /** + * Replaces a pending invitation's email, role and project access. A new + * invitation email is sent only when the address changes. + */ + update( + inviteId: string, + body: UpdateTeamInviteData, + options?: RequestOptions + ): Promise { + return this.request('PUT /team/invites/{inviteId}', { + label: 'team.invites.update', + path: { inviteId }, + body, + options, + }); + } + + /** Revokes a pending invitation. */ + revoke(inviteId: string, options?: RequestOptions): Promise { + return this.request('DELETE /team/invites/{inviteId}', { + label: 'team.invites.revoke', + path: { inviteId }, + options, + }); + } +} + /** The team of the token. Team token. */ export class Team extends ApiResource { /** Team members. */ public readonly members: TeamMembers; + /** Pending team invitations. */ + public readonly invites: TeamInvites; /** @internal */ constructor(transport: Transport) { super(transport, 'Team'); this.members = new TeamMembers(transport); + this.invites = new TeamInvites(transport); } retrieve(query?: NestedQuery, options?: RequestOptions): Promise { diff --git a/test/client.test.ts b/test/client.test.ts index c3db257..22d376a 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -247,6 +247,7 @@ describe('redaction', () => { lettermint.suppressions, lettermint.team, lettermint.team.members, + lettermint.team.invites, lettermint.webhooks, lettermint.webhooks.deliveries, lettermint.emails.compose().from('a@example.test').to('b@example.test').subject('Hi'), diff --git a/test/team-api.test.ts b/test/team-api.test.ts index 6ab7d14..54e53e4 100644 --- a/test/team-api.test.ts +++ b/test/team-api.test.ts @@ -6,6 +6,8 @@ import { type PendingSendMailResponse, type RetryWebhookDeliveryResponse, type RouteData, + type TeamInviteData, + ValidationError, type WebhookDeliveryData, } from '../src'; import { type OperationKey, operations } from '../src/generated/operations'; @@ -21,18 +23,34 @@ const ids = { userId: 'user/id', webhookId: 'webhook_1', deliveryId: 'delivery_1', + inviteId: 'invite/1', }; type Call = (lettermint: Lettermint) => Promise | AsyncGenerator; +/** + * Operations without an SDK method yet. They are added by the next PRs in this + * stack, and the last one removes this list. + */ +const pending = [ + 'DELETE /team/members/{userId}', + 'DELETE /team/roles/{roleId}', + 'GET /team/roles/{roleId}', + 'POST /team/roles', + 'POST /team/roles/{roleId}/duplicate', + 'PUT /team/roles/{roleId}', +] as const satisfies readonly OperationKey[]; +type CoveredKey = Exclude; + /** Every operation of the API, called through its public SDK method. */ -const calls: Record = { +const calls: Record = { 'DELETE /domains/{domainId}': (l) => l.domains.delete(ids.domainId), 'DELETE /projects/{projectId}': (l) => l.projects.delete(ids.projectId), 'DELETE /projects/{projectId}/report-forwarding': (l) => l.projects.reportForwarding.delete(ids.projectId), 'DELETE /routes/{routeId}': (l) => l.routes.delete(ids.routeId), 'DELETE /suppressions/{suppressionId}': (l) => l.suppressions.delete(ids.suppressionId), + 'DELETE /team/invites/{inviteId}': (l) => l.team.invites.revoke(ids.inviteId), 'DELETE /webhooks/{webhookId}': (l) => l.webhooks.delete(ids.webhookId), 'GET /blocked-file-types': (l) => l.blockedFileTypes(), 'GET /domains': (l) => l.domains.list(), @@ -53,6 +71,8 @@ const calls: Record = { 'GET /stats': (l) => l.stats.retrieve({ from: '2026-01-01', to: '2026-01-31' }), 'GET /suppressions': (l) => l.suppressions.list(), 'GET /team': (l) => l.team.retrieve(), + 'GET /team/invites': (l) => l.team.invites.list(), + 'GET /team/invites/{inviteId}': (l) => l.team.invites.retrieve(ids.inviteId), 'GET /team/members': (l) => l.team.members.list(), 'GET /team/members/{userId}': (l) => l.team.members.retrieve(ids.userId), 'GET /team/roles': (l) => l.team.roles(), @@ -92,6 +112,12 @@ const calls: Record = { scope: 'team', emails: ['blocked@example.test'], } as never), + 'POST /team/invites': (l) => + l.team.invites.create({ + email: 'jane@example.test', + role_id: 'role_1', + project_access: { scope: 'all' }, + }), 'POST /webhooks': (l) => l.webhooks.create({ name: 'Hook', @@ -117,6 +143,12 @@ const calls: Record = { role_id: 'role_1', project_access: { scope: 'all' }, }), + 'PUT /team/invites/{inviteId}': (l) => + l.team.invites.update(ids.inviteId, { + email: 'jane@example.test', + role_id: 'role_1', + project_access: { scope: 'all' }, + }), 'PUT /webhooks/{webhookId}': (l) => l.webhooks.update(ids.webhookId, { basic_auth: null }), }; @@ -129,11 +161,11 @@ function responseFor(key: OperationKey): Response { describe('operation coverage', () => { it('has an SDK method for every operation in the generated table', () => { - expect(Object.keys(calls).sort()).toEqual(Object.keys(operations).sort()); - expect(Object.keys(operations)).toHaveLength(60); + expect([...Object.keys(calls), ...pending].sort()).toEqual(Object.keys(operations).sort()); + expect(Object.keys(operations)).toHaveLength(71); }); - it.each(Object.keys(calls) as OperationKey[])('%s', async (key) => { + it.each(Object.keys(calls) as CoveredKey[])('%s', async (key) => { const operation = operations[key]; const { lettermint, requests } = client({}, () => responseFor(key)); await calls[key](lettermint); @@ -339,6 +371,92 @@ describe('request bodies and options', () => { ); }); +describe('team invites', () => { + const invite: TeamInviteData = { + id: 'invite/1', + email: 'jane@example.test', + role: { id: 'role_1', name: 'Member' }, + project_access: { scope: 'selected', projects: [{ id: 'project_1', name: 'Production' }] }, + created_at: '2026-10-20T09:00:00Z', + }; + const body = { + email: 'jane@example.test', + role_id: 'role_1', + project_access: { scope: 'selected' as const, project_ids: ['project_1'] }, + }; + + it('lists, retrieves, creates, updates and revokes invites with the team token', async () => { + const { lettermint, requests } = client({}, (request) => { + if (request.method === 'DELETE') return json(200, { message: 'Invite revoked.' }); + if (request.method === 'POST') return json(201, invite); + if (request.path === '/team/invites') return json(200, { data: [invite], next_cursor: 'c2' }); + return json(200, invite); + }); + + const page = await lettermint.team.invites.list({ page: { size: 10, cursor: 'c1' } }); + expect(page).toEqual({ data: [invite], next_cursor: 'c2' }); + const retrieved: TeamInviteData = await lettermint.team.invites.retrieve('invite/1'); + expect(retrieved).toEqual(invite); + const created: TeamInviteData = await lettermint.team.invites.create(body); + expect(created).toEqual(invite); + const updated: TeamInviteData = await lettermint.team.invites.update('invite/1', { + ...body, + project_access: { scope: 'all' }, + }); + expect(updated).toEqual(invite); + const revoked = await lettermint.team.invites.revoke('invite/1'); + expect(revoked).toEqual({ message: 'Invite revoked.' }); + + expect(requests.map((r) => `${r.method} ${r.url}`)).toEqual([ + 'GET https://api.lettermint.co/v1/team/invites?page%5Bsize%5D=10&page%5Bcursor%5D=c1', + 'GET https://api.lettermint.co/v1/team/invites/invite%2F1', + 'POST https://api.lettermint.co/v1/team/invites', + 'PUT https://api.lettermint.co/v1/team/invites/invite%2F1', + 'DELETE https://api.lettermint.co/v1/team/invites/invite%2F1', + ]); + for (const request of requests) { + expect(request.headers.Authorization).toBe(`Bearer ${TEAM_TOKEN}`); + expect(request.headers).not.toHaveProperty('x-lettermint-token'); + expect(request.headers).not.toHaveProperty('Idempotency-Key'); + } + expect(requests[2].body).toEqual(body); + expect(requests[2].headers['Content-Type']).toBe('application/json'); + expect(requests[3].body).toEqual({ ...body, project_access: { scope: 'all' } }); + for (const index of [0, 1, 4]) { + expect(requests[index].rawBody).toBeUndefined(); + expect(requests[index].headers).not.toHaveProperty('Content-Type'); + } + }); + + it('rejects an invite that conflicts with the team state', async () => { + const conflict = { + error: { code: 'owner_protected', message: 'The Owner role cannot be assigned.' }, + }; + const { lettermint } = client({}, () => json(409, conflict)); + const error = await rejection(lettermint.team.invites.create(body)); + expect(error).toBeInstanceOf(ConflictError); + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ + status: 409, + code: 'owner_protected', + message: 'The Owner role cannot be assigned.', + }); + }); + + it('rejects invalid invite data', async () => { + const invalid = { + message: 'The email field must be a valid email address.', + errors: { email: ['The email field must be a valid email address.'] }, + }; + const { lettermint } = client({}, () => json(422, invalid)); + const error = await rejection( + lettermint.team.invites.update('invite/1', { ...body, email: 'not-an-email' }) + ); + expect(error).toBeInstanceOf(ValidationError); + expect(error).toMatchObject({ status: 422, errors: invalid.errors }); + }); +}); + describe('query parameters', () => { it('serializes nested objects to the bracket syntax', async () => { const { lettermint, requests } = client({}, () => json(200, { data: [], next_cursor: null })); @@ -447,6 +565,7 @@ describe('pagination', () => { ['routes', (l: Lettermint) => l.routes.iterate('p')], ['suppressions', (l: Lettermint) => l.suppressions.iterate()], ['team members', (l: Lettermint) => l.team.members.iterate()], + ['team invites', (l: Lettermint) => l.team.invites.iterate()], ['webhooks', (l: Lettermint) => l.webhooks.iterate()], ['webhook deliveries', (l: Lettermint) => l.webhooks.deliveries.iterate('w')], ])('iterates %s', async (_, iterate) => { @@ -508,6 +627,18 @@ describe('path parameters', () => { await expect(lettermint.webhooks.deliveries.retry('w', id as string)).rejects.toThrow( 'webhooks.deliveries.retry: `deliveryId`' ); + for (const call of [ + (l: Lettermint) => l.team.invites.retrieve(id as string), + (l: Lettermint) => + l.team.invites.update(id as string, { + email: 'jane@example.test', + role_id: 'role_1', + project_access: { scope: 'all' }, + }), + (l: Lettermint) => l.team.invites.revoke(id as string), + ]) { + await expect(call(lettermint)).rejects.toThrow(/^team\.invites\.\w+: `inviteId` must be/); + } expect(fetch).not.toHaveBeenCalled(); }); });