@open-kingdom/shared-backend-feature-user-management 0.0.2-13 → 0.0.2-15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,281 @@
1
- # feature-user-management
1
+ # `@open-kingdom/shared-backend-feature-user-management`
2
2
 
3
- This library was generated with [Nx](https://nx.dev).
3
+ A NestJS dynamic module providing REST endpoints and services for user listing/deletion and the complete invitation lifecycle (create, list, cancel, validate, accept). Integrates with `data-access-users` for persistence and `feature-email` for invitation delivery.
4
4
 
5
- ## Building
5
+ ---
6
6
 
7
- Run `nx build feature-user-management` to build the library.
7
+ ## Exports
8
8
 
9
- ## Running unit tests
9
+ | Export | Kind | Description |
10
+ | ----------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
11
+ | `FeatureUserManagementModule` | `class` | Dynamic NestJS module. Call `.forRoot(options)` to configure. |
12
+ | `UserManagementService` | `class` | User listing and deletion with self-deletion guard. |
13
+ | `InvitationsService` | `class` | Full invitation lifecycle (invite, validate, accept, list, cancel). |
14
+ | `UserManagementModuleOptions` | `interface` | Type-only. Argument type for `.forRoot()`. |
15
+ | `USER_MANAGEMENT_OPTIONS` | `string` | DI token for module options. Value: `'USER_MANAGEMENT_OPTIONS'`. |
16
+ | `EMAIL_SENDER` | `string` | DI token aliasing `EmailService` as `EmailSender`. Value: `'EMAIL_SENDER'`. |
17
+ | `INVITATION_STATUS` | `const` | Status constants object with keys `PENDING`, `ACCEPTED`, and `EXPIRED` mapping to the string values `'pending'`, `'accepted'`, and `'expired'`. |
18
+ | `Role` | `type` | Union of `'guest'`, `'user'`, and `'admin'`. |
19
+ | `InvitationStatus` | `type` | Union of `'pending'`, `'accepted'`, and `'expired'`. |
20
+ | `AuthenticatedRequest` | `interface` | Express `Request` extended with `user: { id: number; email: string }`. |
21
+ | `EmailSender` | `interface` | Contract for the email dependency. Satisfied by `EmailService` from `feature-email`. |
22
+ | `ValidationResult` | `interface` | Return type of `InvitationsService.validate()`. |
23
+ | `invitations` | `BetterSQLite3Table` | Drizzle table definition for the `invitations` table. |
24
+ | `Invitation` | `type` | Inferred from `invitations.$inferSelect`. |
25
+ | `NewInvitation` | `type` | Inferred from `invitations.$inferInsert`. |
26
+ | `InvitationsTableName` | `string` | Literal `'invitations'`. |
27
+ | `userRoles` | `BetterSQLite3Table` | Drizzle table definition for the `user_roles` table. |
28
+ | `UserRole` | `type` | Inferred from `userRoles.$inferSelect`. |
29
+ | `NewUserRole` | `type` | Inferred from `userRoles.$inferInsert`. |
30
+ | `UserRolesTableName` | `string` | Literal `'user_roles'`. |
31
+ | `InviteUserDto` | `class` | Request DTO for `POST /invitations/invite`. |
32
+ | `AcceptInvitationDto` | `class` | Request DTO for `POST /invitations/accept`. |
33
+ | `InvitationResponse` | `type` | `Omit<Invitation, 'token'>` — the token is never returned to clients. |
34
+ | `UserWithoutPassword` | `type` | `Omit<User, 'password'>` — the password is never returned to clients. |
10
35
 
11
- Run `nx test feature-user-management` to execute the unit tests via [Jest](https://jestjs.io).
36
+ ---
37
+
38
+ ## Type Definitions
39
+
40
+ ### `UserManagementModuleOptions`
41
+
42
+ | Property | Type | Required | Default | Description |
43
+ | ----------------------- | -------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------- |
44
+ | `invitationTokenSecret` | `string` | Yes | — | HMAC-SHA256 secret for signing invitation tokens. Compromise allows forged tokens. |
45
+ | `invitationExpiryDays` | `number` | No | `7` | Number of days an invitation token remains valid after creation. |
46
+ | `frontendBaseUrl` | `string` | Yes | — | Base URL of the frontend application. Invitation links use the format `{frontendBaseUrl}/accept-invitation?token={token}`. |
47
+
48
+ ### `Role` and `InvitationStatus`
49
+
50
+ `Role` is a string union of `'guest'`, `'user'`, and `'admin'`. `InvitationStatus` is a string union of `'pending'`, `'accepted'`, and `'expired'`. The `INVITATION_STATUS` constant object provides these string values as named properties for use without string literals.
51
+
52
+ ### `ValidationResult`
53
+
54
+ | Property | Type | Description |
55
+ | -------- | --------------------- | -------------------------------------------------------------------- |
56
+ | `valid` | `boolean` | Whether the token is valid and unexpired. |
57
+ | `email` | `string \| undefined` | Present when `valid` is `true`. The invitee's email address. |
58
+ | `role` | `Role \| undefined` | Present when `valid` is `true`. The role assigned to the invitation. |
59
+
60
+ ### `EmailSender` Interface
61
+
62
+ The `EmailSender` interface is the contract this module uses for its email dependency. It requires a `send` method that accepts an object with `to` (string), `subject` (string), and `body` (string) and returns a `Promise` resolving to an object with `success` (boolean) and an optional `error` (string). This is satisfied by `EmailService` from `feature-email`.
63
+
64
+ ### `invitations` Table Schema
65
+
66
+ | Column | Type | Constraints | Description |
67
+ | ------------- | --------- | ----------------------------- | ------------------------------------------------------------ |
68
+ | `id` | `integer` | Primary key, auto-increment | Unique invitation identifier |
69
+ | `email` | `text` | Not null | Invitee email address |
70
+ | `token` | `text` | Not null, unique | HMAC-SHA256 hex token; never returned to clients via the API |
71
+ | `tokenExpiry` | `integer` | Not null | Unix timestamp (ms) of expiry |
72
+ | `invitedBy` | `integer` | Not null, FK → `users.id` | ID of the user who sent the invitation |
73
+ | `invitedAt` | `integer` | Not null | Unix timestamp (ms) of creation |
74
+ | `role` | `text` | Not null, default `'guest'` | Role to assign on acceptance |
75
+ | `status` | `text` | Not null, default `'pending'` | Current invitation status |
76
+
77
+ ### `user_roles` Table Schema
78
+
79
+ | Column | Type | Constraints | Description |
80
+ | ------------ | --------- | --------------------------- | ------------------------------------------ |
81
+ | `id` | `integer` | Primary key, auto-increment | Unique role assignment identifier |
82
+ | `userId` | `integer` | Not null, FK → `users.id` | The user this role is assigned to |
83
+ | `role` | `text` | Not null, default `'guest'` | The assigned role |
84
+ | `assignedAt` | `integer` | Not null | Unix timestamp (ms) of assignment |
85
+ | `assignedBy` | `integer` | Nullable, FK → `users.id` | The user who made the assignment; nullable |
86
+
87
+ ### `InviteUserDto`
88
+
89
+ | Property | Type | Required | Description |
90
+ | -------- | ------------------------------ | -------- | ---------------------------------------------------- |
91
+ | `email` | `string` | Yes | Email address of the invitee. |
92
+ | `role` | `'guest' \| 'user' \| 'admin'` | No | Role to assign on acceptance. Defaults to `'guest'`. |
93
+
94
+ ### `AcceptInvitationDto`
95
+
96
+ | Property | Type | Required | Description |
97
+ | ----------- | -------- | -------- | -------------------------------------------------------------- |
98
+ | `token` | `string` | Yes | Token from the invitation link. |
99
+ | `password` | `string` | Yes | Plaintext password for the new account (minimum 8 characters). |
100
+ | `firstName` | `string` | No | Optional first name for the new account. |
101
+ | `lastName` | `string` | No | Optional last name for the new account. |
102
+
103
+ ---
104
+
105
+ ## Module Registration
106
+
107
+ ```typescript
108
+ // app.module.ts
109
+ import { Module } from '@nestjs/common';
110
+ import { FeatureUserManagementModule } from '@open-kingdom/shared-backend-feature-user-management';
111
+
112
+ @Module({
113
+ imports: [
114
+ // DatabaseSetupModule.register(...) must be registered globally first.
115
+ // EmailModule.forRoot(...) must be registered globally first.
116
+ FeatureUserManagementModule.forRoot({
117
+ invitationTokenSecret: process.env['INVITATION_SECRET']!,
118
+ invitationExpiryDays: 7,
119
+ frontendBaseUrl: process.env['FRONTEND_URL']!,
120
+ }),
121
+ ],
122
+ })
123
+ export class AppModule {}
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Configuration Options
129
+
130
+ | Option | Type | Default | Description |
131
+ | ----------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
132
+ | `invitationTokenSecret` | `string` | — (required) | HMAC-SHA256 secret for token generation. Keep secret; compromise allows forged invitation tokens. |
133
+ | `invitationExpiryDays` | `number` | `7` | Number of days an invitation token remains valid after creation. |
134
+ | `frontendBaseUrl` | `string` | — (required) | Base URL of the frontend application. Invitation emails contain a link in the format `{frontendBaseUrl}/accept-invitation?token={token}`. |
135
+
136
+ ---
137
+
138
+ ## What `forRoot()` Registers
139
+
140
+ | Component | Role |
141
+ | ----------------------------------------- | -------------------------------------------------------------------------- |
142
+ | `OpenKingdomDataAccessBackendUsersModule` | imported; provides `UsersService` |
143
+ | `USER_MANAGEMENT_OPTIONS` | value provider for `UserManagementModuleOptions` |
144
+ | `EMAIL_SENDER` | `useExisting: EmailService` — aliases the globally provided `EmailService` |
145
+ | `InvitationsService` | invitation lifecycle management |
146
+ | `UserManagementService` | user listing and deletion |
147
+ | `InvitationsController` | registers invitation routes |
148
+ | `UsersController` | registers user routes |
149
+
150
+ Exported from the module: `InvitationsService`, `UserManagementService`.
151
+
152
+ ---
153
+
154
+ ## REST Endpoints
155
+
156
+ All authenticated endpoints require a JWT Bearer token. The `UsersController` applies `AuthGuard('jwt')` at the controller level. The `InvitationsController` applies `AuthGuard('jwt')` per-route.
157
+
158
+ ### Users
159
+
160
+ | Method | Path | Auth | Description |
161
+ | -------- | ------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
162
+ | `GET` | `/users` | Required | List all users (passwords excluded). |
163
+ | `GET` | `/users/:id` | Required | Get a single user by ID. |
164
+ | `DELETE` | `/users/:id` | Required | Delete a user. Throws `403 Forbidden` if `:id` matches the requester's own ID. Throws `404 Not Found` if user does not exist. |
165
+
166
+ ### Invitations
167
+
168
+ | Method | Path | Auth | Description |
169
+ | -------- | ------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------- |
170
+ | `GET` | `/invitations` | Required | List pending and expired invitations (accepted ones excluded). Stale invitations are auto-expired on retrieval. |
171
+ | `POST` | `/invitations/invite` | Required | Create and send an invitation. Returns `InvitationResponse` (token excluded). |
172
+ | `GET` | `/invitations/validate/:token` | None | Validate a token. Returns `ValidationResult`. |
173
+ | `POST` | `/invitations/accept` | None | Accept an invitation and create a user account. Returns the new user (password excluded). |
174
+ | `DELETE` | `/invitations/:id` | Required | Cancel and permanently delete an invitation record. |
175
+
176
+ ### Request/Response Shapes
177
+
178
+ **`POST /invitations/invite`** accepts a body with `email` (string, required) and `role` (optional, defaults to `'guest'`). On success (`201`) it returns an `InvitationResponse` object containing `id`, `email`, `tokenExpiry` (Unix ms), `invitedBy` (user ID), `invitedAt` (Unix ms), `role`, and `status`. The `token` field is never included in the response.
179
+
180
+ Error responses for `POST /invitations/invite`:
181
+
182
+ - `400 Bad Request` — a user with that email already exists, or a non-expired pending invitation already exists for that email.
183
+ - `502 Bad Gateway` — email delivery failed; the invitation record is rolled back.
184
+
185
+ **`GET /invitations/validate/:token`** returns `{ valid: true, email: "...", role: "..." }` for a valid token, or `{ valid: false }` for an unknown, expired, or already-accepted token.
186
+
187
+ **`POST /invitations/accept`** accepts a body with `token` (string, required), `password` (string, required, min 8 chars), and optional `firstName` and `lastName`. On success (`201`) it returns the newly created user object with `id`, `firstName`, `lastName`, and `email` — the password is never included.
188
+
189
+ ---
190
+
191
+ ## UserManagementService API
192
+
193
+ | Method | Parameters | Returns | Description |
194
+ | ---------- | --------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
195
+ | `findAll` | — | `Promise<UserWithoutPassword[]>` | Returns all users with password excluded. |
196
+ | `findById` | `id: number` | `Promise<UserWithoutPassword>` | Returns a user by ID. Throws `NotFoundException` if not found. |
197
+ | `delete` | `id: number, requesterId: number` | `Promise<void>` | Deletes a user. Throws `ForbiddenException` if `id === requesterId`. Throws `NotFoundException` if user does not exist. |
198
+
199
+ ---
200
+
201
+ ## InvitationsService API
202
+
203
+ | Method | Parameters | Returns | Description |
204
+ | ---------- | ------------------------------------------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
205
+ | `invite` | `email: string, role: Role, invitedById: number` | `Promise<InvitationResponse>` | Creates an invitation, sends an email, and returns `InvitationResponse` (no token). Throws `BadRequestException` if a user or pending invitation already exists for the email. Throws `BadGatewayException` if email delivery fails (invitation is rolled back). |
206
+ | `validate` | `token: string` | `Promise<ValidationResult>` | Validates a token. Returns `{ valid: false }` for unknown, expired, or already-accepted tokens. |
207
+ | `accept` | `token: string, password: string, firstName?: string, lastName?: string` | `Promise<User>` | Validates the token, creates a user via `UsersService.create()`, and marks the invitation as accepted. Throws `BadRequestException` for invalid or expired tokens. |
208
+ | `findAll` | — | `Promise<InvitationResponse[]>` | Lists all non-accepted invitations. Stale pending invitations are auto-expired before returning. |
209
+ | `cancel` | `id: number` | `Promise<void>` | Permanently deletes an invitation record. Throws `NotFoundException` if not found. |
210
+
211
+ ### Token Generation
212
+
213
+ Invitation tokens are HMAC-SHA256 hex strings. The signed payload combines the invitee's email, the expiry timestamp, and 16 random bytes. Tokens are stored in the database and delivered only via email — they are never returned by any API response.
214
+
215
+ ---
216
+
217
+ ## Drizzle Schema Inclusion
218
+
219
+ The `invitations` and `userRoles` table definitions must be included in the schema passed to `DatabaseSetupModule.register()`:
220
+
221
+ ```typescript
222
+ import { invitations, userRoles } from '@open-kingdom/shared-backend-feature-user-management';
223
+ import { users } from '@open-kingdom/shared-backend-data-access-users';
224
+
225
+ DatabaseSetupModule.register({
226
+ schema: { users, invitations, userRoles },
227
+ filename: 'app.db',
228
+ });
229
+ ```
230
+
231
+ ---
232
+
233
+ ## Full Application Registration Pattern
234
+
235
+ ```typescript
236
+ // app.module.ts
237
+ import { Module } from '@nestjs/common';
238
+ import { APP_GUARD } from '@nestjs/core';
239
+ import { DatabaseSetupModule } from '@open-kingdom/shared-backend-data-access-database-setup';
240
+ import { OpenKingdomFeatureBackendAuthModule, JwtAuthGuard } from '@open-kingdom/shared-backend-feature-authentication';
241
+ import { EmailModule } from '@open-kingdom/shared-backend-feature-email';
242
+ import { FeatureUserManagementModule } from '@open-kingdom/shared-backend-feature-user-management';
243
+ import { users } from '@open-kingdom/shared-backend-data-access-users';
244
+ import { invitations, userRoles } from '@open-kingdom/shared-backend-feature-user-management';
245
+
246
+ @Module({
247
+ imports: [
248
+ DatabaseSetupModule.register({
249
+ schema: { users, invitations, userRoles },
250
+ filename: process.env['DB_FILENAME'] ?? 'app.db',
251
+ pragmas: { journal_mode: 'WAL', foreign_keys: 'ON' },
252
+ }),
253
+ OpenKingdomFeatureBackendAuthModule.forRoot({
254
+ jwtSecret: process.env['JWT_SECRET']!,
255
+ }),
256
+ EmailModule.forRoot({
257
+ provider: 'gmail',
258
+ config: {
259
+ clientEmail: process.env['GMAIL_CLIENT_EMAIL']!,
260
+ privateKey: process.env['GMAIL_PRIVATE_KEY']!,
261
+ impersonateEmail: process.env['GMAIL_IMPERSONATE_EMAIL']!,
262
+ },
263
+ }),
264
+ FeatureUserManagementModule.forRoot({
265
+ invitationTokenSecret: process.env['INVITATION_SECRET']!,
266
+ frontendBaseUrl: process.env['FRONTEND_URL']!,
267
+ invitationExpiryDays: 7,
268
+ }),
269
+ ],
270
+ providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }],
271
+ })
272
+ export class AppModule {}
273
+ ```
274
+
275
+ ---
276
+
277
+ ## Testing
278
+
279
+ ```bash
280
+ nx test @open-kingdom/shared-backend-feature-user-management
281
+ ```
@@ -4,6 +4,7 @@ import type { AuthenticatedRequest } from '../types';
4
4
  export declare class InvitationsController {
5
5
  private readonly invitationsService;
6
6
  constructor(invitationsService: InvitationsService);
7
+ findAll(): Promise<import("../services").InvitationResponse[]>;
7
8
  invite(dto: InviteUserDto, req: AuthenticatedRequest): Promise<import("../services").InvitationResponse>;
8
9
  validate(token: string): Promise<import("../types").ValidationResult>;
9
10
  accept(dto: AcceptInvitationDto): Promise<{
@@ -12,5 +13,8 @@ export declare class InvitationsController {
12
13
  firstName: string | null;
13
14
  lastName: string | null;
14
15
  }>;
16
+ cancel(id: number): Promise<{
17
+ message: string;
18
+ }>;
15
19
  }
16
20
  //# sourceMappingURL=invitations.controller.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"invitations.controller.d.ts","sourceRoot":"","sources":["../../../src/lib/controllers/invitations.controller.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,QAAQ,CAAC;AAC5D,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AAErD,qBAEa,qBAAqB;IACpB,OAAO,CAAC,QAAQ,CAAC,kBAAkB;gBAAlB,kBAAkB,EAAE,kBAAkB;IAoB7D,MAAM,CACF,GAAG,EAAE,aAAa,EACf,GAAG,EAAE,oBAAoB;IA8BhC,QAAQ,CAAiB,KAAK,EAAE,MAAM;IAiBtC,MAAM,CAAS,GAAG,EAAE,mBAAmB;;;;;;CAW9C"}
1
+ {"version":3,"file":"invitations.controller.d.ts","sourceRoot":"","sources":["../../../src/lib/controllers/invitations.controller.ts"],"names":[],"mappings":"AAuBA,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,QAAQ,CAAC;AAC5D,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AAErD,qBAEa,qBAAqB;IACpB,OAAO,CAAC,QAAQ,CAAC,kBAAkB;gBAAlB,kBAAkB,EAAE,kBAAkB;IAiB7D,OAAO;IA0BP,MAAM,CACF,GAAG,EAAE,aAAa,EACf,GAAG,EAAE,oBAAoB;IA8BhC,QAAQ,CAAiB,KAAK,EAAE,MAAM;IAiBtC,MAAM,CAAS,GAAG,EAAE,mBAAmB;;;;;;IA8BvC,MAAM,CAA4B,EAAE,EAAE,MAAM;;;CAInD"}
@@ -11,6 +11,9 @@ let InvitationsController = class InvitationsController {
11
11
  constructor(invitationsService) {
12
12
  this.invitationsService = invitationsService;
13
13
  }
14
+ async findAll() {
15
+ return this.invitationsService.findAll();
16
+ }
14
17
  async invite(dto, req) {
15
18
  return this.invitationsService.invite(dto.email, dto.role ?? 'guest', req.user.id);
16
19
  }
@@ -22,8 +25,31 @@ let InvitationsController = class InvitationsController {
22
25
  const { password: _password, ...userWithoutPassword } = user;
23
26
  return userWithoutPassword;
24
27
  }
28
+ async cancel(id) {
29
+ await this.invitationsService.cancel(id);
30
+ return { message: 'Invitation cancelled successfully' };
31
+ }
25
32
  };
26
33
  exports.InvitationsController = InvitationsController;
34
+ tslib_1.__decorate([
35
+ (0, common_1.Get)(),
36
+ (0, swagger_1.ApiBearerAuth)('JWT-auth'),
37
+ (0, common_1.UseGuards)((0, passport_1.AuthGuard)('jwt')),
38
+ (0, swagger_1.ApiOperation)({
39
+ summary: 'List pending and expired invitations',
40
+ description: 'Returns pending and expired invitations. Accepted invitations are excluded. Expired invitations are auto-updated.',
41
+ }),
42
+ (0, swagger_1.ApiResponse)({
43
+ status: 200,
44
+ description: 'List of invitations',
45
+ }),
46
+ (0, swagger_1.ApiUnauthorizedResponse)({
47
+ description: 'Unauthorized - Invalid or missing JWT token',
48
+ }),
49
+ tslib_1.__metadata("design:type", Function),
50
+ tslib_1.__metadata("design:paramtypes", []),
51
+ tslib_1.__metadata("design:returntype", Promise)
52
+ ], InvitationsController.prototype, "findAll", null);
27
53
  tslib_1.__decorate([
28
54
  (0, common_1.Post)('invite'),
29
55
  (0, swagger_1.ApiBearerAuth)('JWT-auth'),
@@ -40,6 +66,10 @@ tslib_1.__decorate([
40
66
  (0, swagger_1.ApiBadRequestResponse)({
41
67
  description: 'User already exists or pending invitation exists',
42
68
  }),
69
+ (0, swagger_1.ApiResponse)({
70
+ status: 502,
71
+ description: 'Email delivery failed — invitation rolled back',
72
+ }),
43
73
  (0, swagger_1.ApiUnauthorizedResponse)({
44
74
  description: 'Unauthorized - Invalid or missing JWT token',
45
75
  }),
@@ -95,6 +125,29 @@ tslib_1.__decorate([
95
125
  tslib_1.__metadata("design:paramtypes", [dto_1.AcceptInvitationDto]),
96
126
  tslib_1.__metadata("design:returntype", Promise)
97
127
  ], InvitationsController.prototype, "accept", null);
128
+ tslib_1.__decorate([
129
+ (0, common_1.Delete)(':id'),
130
+ (0, swagger_1.ApiBearerAuth)('JWT-auth'),
131
+ (0, common_1.UseGuards)((0, passport_1.AuthGuard)('jwt')),
132
+ (0, swagger_1.ApiOperation)({
133
+ summary: 'Cancel an invitation',
134
+ description: 'Cancel and delete an invitation regardless of its current status.',
135
+ }),
136
+ (0, swagger_1.ApiResponse)({
137
+ status: 200,
138
+ description: 'Invitation cancelled successfully',
139
+ }),
140
+ (0, swagger_1.ApiNotFoundResponse)({
141
+ description: 'Invitation not found',
142
+ }),
143
+ (0, swagger_1.ApiUnauthorizedResponse)({
144
+ description: 'Unauthorized - Invalid or missing JWT token',
145
+ }),
146
+ tslib_1.__param(0, (0, common_1.Param)('id', common_1.ParseIntPipe)),
147
+ tslib_1.__metadata("design:type", Function),
148
+ tslib_1.__metadata("design:paramtypes", [Number]),
149
+ tslib_1.__metadata("design:returntype", Promise)
150
+ ], InvitationsController.prototype, "cancel", null);
98
151
  exports.InvitationsController = InvitationsController = tslib_1.__decorate([
99
152
  (0, swagger_1.ApiTags)('Invitations'),
100
153
  (0, common_1.Controller)('invitations'),
@@ -17,8 +17,12 @@ export declare class InvitationsService {
17
17
  invite(email: string, role: Role, invitedById: number): Promise<InvitationResponse>;
18
18
  validate(token: string): Promise<ValidationResult>;
19
19
  accept(token: string, password: string, firstName?: string, lastName?: string): Promise<User>;
20
+ findAll(): Promise<InvitationResponse[]>;
21
+ cancel(id: number): Promise<void>;
20
22
  private ensureUserDoesNotExist;
21
23
  private ensureNoPendingInvitation;
24
+ private findInvitationOrFail;
25
+ private expireStaleInvitations;
22
26
  private isExpired;
23
27
  private findByToken;
24
28
  private markAsExpired;
@@ -1 +1 @@
1
- {"version":3,"file":"invitations.service.d.ts","sourceRoot":"","sources":["../../../src/lib/services/invitations.service.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AAKnE,OAAO,EACL,YAAY,EACZ,IAAI,EACJ,KAAK,EACL,cAAc,EACf,MAAM,gDAAgD,CAAC;AACxD,OAAO,EACL,WAAW,EACX,UAAU,EACV,oBAAoB,EACrB,MAAM,+BAA+B,CAAC;AAUvC,OAAO,KAAK,EACV,2BAA2B,EAC3B,WAAW,EACX,IAAI,EACJ,gBAAgB,EACjB,MAAM,UAAU,CAAC;AAElB,MAAM,MAAM,kBAAkB,GAAG,IAAI,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;AAE3D,KAAK,MAAM,GAAG;IACZ,CAAC,cAAc,CAAC,EAAE,OAAO,KAAK,CAAC;IAC/B,CAAC,oBAAoB,CAAC,EAAE,OAAO,WAAW,CAAC;CAC5C,CAAC;AAKF,qBACa,kBAAkB;IAIX,OAAO,CAAC,QAAQ,CAAC,EAAE;IAEnC,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,YAAY;IACK,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAPjE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAuC;gBAG3B,EAAE,EAAE,qBAAqB,CAAC,MAAM,CAAC,EAEjD,OAAO,EAAE,2BAA2B,EACpC,YAAY,EAAE,YAAY,EACQ,WAAW,CAAC,EAAE,WAAW,YAAA;IAGxE,MAAM,CACV,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,IAAI,EACV,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,kBAAkB,CAAC;IAqCxB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAmBlD,MAAM,CACV,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,SAAS,CAAC,EAAE,MAAM,EAClB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC;YAmBF,sBAAsB;YAQtB,yBAAyB;IAevC,OAAO,CAAC,SAAS;YAIH,WAAW;YAMX,aAAa;YAOb,cAAc;IAO5B,OAAO,KAAK,UAAU,GAErB;IAED,OAAO,CAAC,aAAa;YAUP,mBAAmB;CAyBlC"}
1
+ {"version":3,"file":"invitations.service.d.ts","sourceRoot":"","sources":["../../../src/lib/services/invitations.service.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AAKnE,OAAO,EACL,YAAY,EACZ,IAAI,EACJ,KAAK,EACL,cAAc,EACf,MAAM,gDAAgD,CAAC;AACxD,OAAO,EACL,WAAW,EACX,UAAU,EACV,oBAAoB,EACrB,MAAM,+BAA+B,CAAC;AAUvC,OAAO,KAAK,EACV,2BAA2B,EAC3B,WAAW,EACX,IAAI,EACJ,gBAAgB,EACjB,MAAM,UAAU,CAAC;AAElB,MAAM,MAAM,kBAAkB,GAAG,IAAI,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;AAE3D,KAAK,MAAM,GAAG;IACZ,CAAC,cAAc,CAAC,EAAE,OAAO,KAAK,CAAC;IAC/B,CAAC,oBAAoB,CAAC,EAAE,OAAO,WAAW,CAAC;CAC5C,CAAC;AAKF,qBACa,kBAAkB;IAIX,OAAO,CAAC,QAAQ,CAAC,EAAE;IAEnC,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,YAAY;IACK,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAPjE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAuC;gBAG3B,EAAE,EAAE,qBAAqB,CAAC,MAAM,CAAC,EAEjD,OAAO,EAAE,2BAA2B,EACpC,YAAY,EAAE,YAAY,EACQ,WAAW,CAAC,EAAE,WAAW,YAAA;IAGxE,MAAM,CACV,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,IAAI,EACV,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,kBAAkB,CAAC;IAuCxB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAmBlD,MAAM,CACV,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,SAAS,CAAC,EAAE,MAAM,EAClB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC;IAmBV,OAAO,IAAI,OAAO,CAAC,kBAAkB,EAAE,CAAC;IASxC,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YAKzB,sBAAsB;YAQtB,yBAAyB;YAezB,oBAAoB;YAYpB,sBAAsB;IAYpC,OAAO,CAAC,SAAS;YAIH,WAAW;YAMX,aAAa;YAOb,cAAc;IAO5B,OAAO,KAAK,UAAU,GAErB;IAED,OAAO,CAAC,aAAa;YAUP,mBAAmB;CA8BlC"}
@@ -40,9 +40,9 @@ let InvitationsService = InvitationsService_1 = class InvitationsService {
40
40
  await this.sendInvitationEmail(email, token);
41
41
  }
42
42
  catch (error) {
43
- this.logger.error(`Failed to send invitation email to ${email}, rolling back invitation`, error);
43
+ this.logger.error(`Failed to send invitation email to ${email}: ${error instanceof Error ? error.message : 'Unknown error'}`, error instanceof Error ? error.stack : undefined);
44
44
  await this.db.delete(invitations_schema_1.invitations).where((0, drizzle_orm_1.eq)(invitations_schema_1.invitations.token, token));
45
- throw new common_1.BadRequestException('Failed to send invitation email. Please try again.');
45
+ throw new common_1.BadGatewayException('Invitation could not be sent - email delivery failed');
46
46
  }
47
47
  // Never return token - it's only sent via email
48
48
  const { token: _token, ...invitationWithoutToken } = invitation;
@@ -77,6 +77,17 @@ let InvitationsService = InvitationsService_1 = class InvitationsService {
77
77
  await this.markAsAccepted(token);
78
78
  return user;
79
79
  }
80
+ async findAll() {
81
+ const records = await this.db.query.invitations.findMany();
82
+ await this.expireStaleInvitations(records);
83
+ return records
84
+ .filter((r) => r.status !== types_1.INVITATION_STATUS.ACCEPTED)
85
+ .map(({ token: _, ...rest }) => rest);
86
+ }
87
+ async cancel(id) {
88
+ await this.findInvitationOrFail(id);
89
+ await this.db.delete(invitations_schema_1.invitations).where((0, drizzle_orm_1.eq)(invitations_schema_1.invitations.id, id));
90
+ }
80
91
  async ensureUserDoesNotExist(email) {
81
92
  const existingUser = await this.usersService.findOne(email);
82
93
  if (existingUser) {
@@ -92,6 +103,24 @@ let InvitationsService = InvitationsService_1 = class InvitationsService {
92
103
  throw new common_1.BadRequestException('A pending invitation already exists for this email');
93
104
  }
94
105
  }
106
+ async findInvitationOrFail(id) {
107
+ const invitation = await this.db.query.invitations.findFirst({
108
+ where: (0, drizzle_orm_1.eq)(invitations_schema_1.invitations.id, id),
109
+ });
110
+ if (!invitation) {
111
+ throw new common_1.NotFoundException('Invitation not found');
112
+ }
113
+ return invitation;
114
+ }
115
+ async expireStaleInvitations(records) {
116
+ for (const record of records) {
117
+ if (record.status === types_1.INVITATION_STATUS.PENDING &&
118
+ this.isExpired(record.tokenExpiry)) {
119
+ await this.markAsExpired(record.id);
120
+ record.status = types_1.INVITATION_STATUS.EXPIRED;
121
+ }
122
+ }
123
+ }
95
124
  isExpired(tokenExpiry) {
96
125
  return tokenExpiry < Date.now();
97
126
  }
@@ -130,7 +159,7 @@ let InvitationsService = InvitationsService_1 = class InvitationsService {
130
159
  this.logger.warn('EmailSender not configured - skipping invitation email');
131
160
  return;
132
161
  }
133
- await this.emailSender.send({
162
+ const result = await this.emailSender.send({
134
163
  to: email,
135
164
  subject: templates_1.INVITATION_EMAIL_SUBJECT,
136
165
  body: (0, templates_1.buildInvitationEmailBody)({
@@ -138,6 +167,9 @@ let InvitationsService = InvitationsService_1 = class InvitationsService {
138
167
  expiryDays: this.expiryDays,
139
168
  }),
140
169
  });
170
+ if (!result.success) {
171
+ throw new Error(result.error || 'Email delivery failed');
172
+ }
141
173
  this.logger.log(`Invitation email sent to ${email}`);
142
174
  }
143
175
  };
@@ -31,6 +31,7 @@ export interface EmailSender {
31
31
  body: string;
32
32
  }): Promise<{
33
33
  success: boolean;
34
+ error?: string;
34
35
  }>;
35
36
  }
36
37
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lib/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAGvC,eAAO,MAAM,uBAAuB,4BAA4B,CAAC;AACjE,eAAO,MAAM,YAAY,iBAAiB,CAAC;AAG3C,MAAM,WAAW,2BAA2B;IAC1C,qBAAqB,EAAE,MAAM,CAAC;IAC9B,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,eAAe,EAAE,MAAM,CAAC;CACzB;AAGD,MAAM,WAAW,oBAAqB,SAAQ,OAAO;IACnD,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CACrC;AAGD,MAAM,MAAM,IAAI,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC;AAE9C,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,CAAC;AAElE,eAAO,MAAM,iBAAiB;;;;CAIpB,CAAC;AAEX,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,IAAI,CAAC;CACb;AAGD,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,OAAO,EAAE;QACZ,EAAE,EAAE,MAAM,CAAC;QACX,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACnC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lib/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAGvC,eAAO,MAAM,uBAAuB,4BAA4B,CAAC;AACjE,eAAO,MAAM,YAAY,iBAAiB,CAAC;AAG3C,MAAM,WAAW,2BAA2B;IAC1C,qBAAqB,EAAE,MAAM,CAAC;IAC9B,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,eAAe,EAAE,MAAM,CAAC;CACzB;AAGD,MAAM,WAAW,oBAAqB,SAAQ,OAAO;IACnD,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CACrC;AAGD,MAAM,MAAM,IAAI,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC;AAE9C,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,CAAC;AAElE,eAAO,MAAM,iBAAiB;;;;CAIpB,CAAC;AAEX,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,IAAI,CAAC;CACb;AAGD,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,OAAO,EAAE;QACZ,EAAE,EAAE,MAAM,CAAC;QACX,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACnD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-kingdom/shared-backend-feature-user-management",
3
- "version": "0.0.2-13",
3
+ "version": "0.0.2-15",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -17,6 +17,7 @@
17
17
  }
18
18
  },
19
19
  "files": [
20
+ "README.md",
20
21
  "dist",
21
22
  "!**/*.tsbuildinfo"
22
23
  ],
@@ -32,9 +33,9 @@
32
33
  "@nestjs/common": "^11.0.0",
33
34
  "@nestjs/passport": "^11.0.5",
34
35
  "@nestjs/swagger": "^11.2.3",
35
- "@open-kingdom/shared-backend-data-access-users": "0.0.2-13",
36
- "@open-kingdom/shared-backend-feature-email": "0.0.2-13",
37
- "@open-kingdom/shared-poly-util-constants": "0.0.2-13",
36
+ "@open-kingdom/shared-backend-data-access-users": "0.0.2-15",
37
+ "@open-kingdom/shared-backend-feature-email": "0.0.2-15",
38
+ "@open-kingdom/shared-poly-util-constants": "0.0.2-15",
38
39
  "drizzle-orm": "^0.44.5",
39
40
  "express": "5.2.1"
40
41
  }