@open-kingdom/shared-backend-feature-user-management 0.0.2-14 → 0.0.2-16
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 +276 -6
- package/dist/lib/controllers/invitations.controller.d.ts +4 -0
- package/dist/lib/controllers/invitations.controller.d.ts.map +1 -1
- package/dist/lib/controllers/invitations.controller.js +53 -0
- package/dist/lib/services/invitations.service.d.ts +4 -0
- package/dist/lib/services/invitations.service.d.ts.map +1 -1
- package/dist/lib/services/invitations.service.js +35 -3
- package/dist/lib/types.d.ts +1 -0
- package/dist/lib/types.d.ts.map +1 -1
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -1,11 +1,281 @@
|
|
|
1
|
-
# feature-user-management
|
|
1
|
+
# `@open-kingdom/shared-backend-feature-user-management`
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
5
|
+
---
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Exports
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
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":"
|
|
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":"
|
|
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}
|
|
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.
|
|
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
|
};
|
package/dist/lib/types.d.ts
CHANGED
package/dist/lib/types.d.ts.map
CHANGED
|
@@ -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;
|
|
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-
|
|
3
|
+
"version": "0.0.2-16",
|
|
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-
|
|
36
|
-
"@open-kingdom/shared-backend-feature-email": "0.0.2-
|
|
37
|
-
"@open-kingdom/shared-poly-util-constants": "0.0.2-
|
|
36
|
+
"@open-kingdom/shared-backend-data-access-users": "0.0.2-16",
|
|
37
|
+
"@open-kingdom/shared-backend-feature-email": "0.0.2-16",
|
|
38
|
+
"@open-kingdom/shared-poly-util-constants": "0.0.2-16",
|
|
38
39
|
"drizzle-orm": "^0.44.5",
|
|
39
40
|
"express": "5.2.1"
|
|
40
41
|
}
|