@x-sls/dynamodb-users 1.0.0
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 +199 -0
- package/package.json +14 -0
- package/src/__tests__/index.test.js +251 -0
- package/src/index.js +416 -0
package/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# @x-sls/dynamodb-users
|
|
2
|
+
|
|
3
|
+
Helpers for managing users in DynamoDB: create, update, get by ID or email, and list users by lookup list (e.g. active/inactive). The library never creates a DynamoDB client; you pass in a DocumentClient and table name.
|
|
4
|
+
|
|
5
|
+
## Table requirements
|
|
6
|
+
|
|
7
|
+
- **Base table:** partition key `pk` (String), sort key `sk` (String).
|
|
8
|
+
- **GSI (default name `gsi1`):** partition key `pk1` (String), sort key `sk1` (String).
|
|
9
|
+
|
|
10
|
+
If your table uses different key or index names, override them via `options.schema` (see [Schema](#schema)).
|
|
11
|
+
|
|
12
|
+
## Data model
|
|
13
|
+
|
|
14
|
+
Each user is represented by:
|
|
15
|
+
|
|
16
|
+
1. **User item** — Main record: `pk = U#<uuid>`, `sk = A`, plus `pk1`/`sk1` for email lookup on the GSI (e.g. `pk1 = email#<normalized>`, `sk1 = A`). Get by ID via base table; get by email via GSI query.
|
|
17
|
+
2. **Email item** — Sentinel for by-email lookup when the GSI is eventually consistent: `pk = EMAIL#<normalized>`, `sk = A`, `userId`. The library queries the GSI first, then falls back to this item + `getUser`.
|
|
18
|
+
3. **LOOKUP item (optional)** — For list queries: `pk = U#<id>`, `sk = LOOKUP`, `pk1 = <lookupList>` (e.g. `USER#ACTIVE`), `sk1 = <normalized name>`. Projected attributes (e.g. email, name, picture, userId). Created/updated when `createLookupItem` / `updateLookupItem` are not set to `false`.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install @x-sls/dynamodb-users
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Peer dependency:** `@aws-sdk/lib-dynamodb` (DocumentClient). You must provide your own DynamoDB client and pass it via options.
|
|
27
|
+
|
|
28
|
+
## API
|
|
29
|
+
|
|
30
|
+
All functions take an `options` object that must include `documentClient` and `tableName`. Optional `schema` overrides the default key/index names and prefixes.
|
|
31
|
+
|
|
32
|
+
### getUser(userId, options)
|
|
33
|
+
|
|
34
|
+
Get a user by ID (with or without `U#` prefix).
|
|
35
|
+
|
|
36
|
+
- **Returns:** User item or `null`.
|
|
37
|
+
- **Options:** `documentClient`, `tableName` (required); `schema` (optional).
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
const user = await getUser('abc-123', { documentClient, tableName });
|
|
41
|
+
const user2 = await getUser('U#abc-123', { documentClient, tableName });
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### getUserByEmail(email, options)
|
|
45
|
+
|
|
46
|
+
Get a user by email. Queries the GSI first; if empty, falls back to the EMAIL sentinel and then `getUser`.
|
|
47
|
+
|
|
48
|
+
- **Returns:** User item or `null`.
|
|
49
|
+
- **Options:** `documentClient`, `tableName` (required); `schema` (optional; must have `lookupIndex` for by-email); `normalizeEmailFn` (optional, default: lowercased trim).
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
const user = await getUserByEmail('alice@example.com', { documentClient, tableName });
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### createUser(input, options)
|
|
56
|
+
|
|
57
|
+
Create a user atomically: user item + email sentinel. By default also writes a LOOKUP item so the user can be returned by `listUsers` (opt-out with `createLookupItem: false`).
|
|
58
|
+
|
|
59
|
+
- **Input:** `email`, `name` (required). Other keys (e.g. `picture`, `status`) are stored on the user item.
|
|
60
|
+
- **Returns:** The created user item.
|
|
61
|
+
- **Throws:** `"User with this email already exists"` on duplicate email (transaction conditional check).
|
|
62
|
+
- **Options:** `documentClient`, `tableName` (required); `createLookupItem` (default `true`); `lookupList` (optional, default from `schema.defaultLookupList` e.g. `USER#ACTIVE`); `lookupExtraAttributes` (array of attribute names to add to LOOKUP); `extraTransactItems`, `normalizeEmailFn`, `schema` (optional).
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
const user = await createUser(
|
|
66
|
+
{ email: 'alice@example.com', name: 'Alice' },
|
|
67
|
+
{ documentClient, tableName }
|
|
68
|
+
);
|
|
69
|
+
// With custom list
|
|
70
|
+
await createUser(
|
|
71
|
+
{ email: 'bob@example.com', name: 'Bob' },
|
|
72
|
+
{ documentClient, tableName, lookupList: 'USER#INACTIVE' }
|
|
73
|
+
);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### updateUser(userId, updates, options)
|
|
77
|
+
|
|
78
|
+
Update a user. Writes a version record (if `createVersionRecord` is true), updates the user item, and on email change updates/deletes/puts email sentinels. By default also (re)writes the LOOKUP item (opt-out with `updateLookupItem: false`).
|
|
79
|
+
|
|
80
|
+
- **Returns:** Updated user item.
|
|
81
|
+
- **Throws:** `"User not found"`, or `"User with this email already exists"` if email is changed to an existing one.
|
|
82
|
+
- **Options:** `documentClient`, `tableName`, `modifiedBy`, `modifiedReason` (required); `createVersionRecord` (default `true`); `updateLookupItem` (default `true`); `lookupList` (optional); `lookupExtraAttributes`; `allowedUpdateFields` (if set, only these keys are updated); `extraTransactItems`, `normalizeEmailFn`, `schema` (optional).
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
await updateUser(
|
|
86
|
+
userId,
|
|
87
|
+
{ name: 'Alice Smith' },
|
|
88
|
+
{ documentClient, tableName, modifiedBy: 'admin', modifiedReason: 'name change' }
|
|
89
|
+
);
|
|
90
|
+
// Move to inactive list
|
|
91
|
+
await updateUser(
|
|
92
|
+
userId,
|
|
93
|
+
{},
|
|
94
|
+
{ documentClient, tableName, modifiedBy: 'admin', modifiedReason: 'deactivate', lookupList: 'USER#INACTIVE' }
|
|
95
|
+
);
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### listUsers(options)
|
|
99
|
+
|
|
100
|
+
List users by querying the LOOKUP index (GSI). Only returns users for whom LOOKUP items exist (created/updated by this library with LOOKUP enabled). If you never create LOOKUP items, `listUsers` will return empty results; implement your own list logic in that case.
|
|
101
|
+
|
|
102
|
+
- **Returns:** `{ items: object[], lastEvaluatedKey: object | null }`. Each item is a LOOKUP row (projected attributes + `userId`). Use `lastEvaluatedKey` for pagination.
|
|
103
|
+
- **Options:** `documentClient`, `tableName`, `lookupList` (required, e.g. `'USER#ACTIVE'`); `beginsWith` (optional, prefix match on normalized name); `limit` (default 50, max 100); `exclusiveStartKey`, `scanIndexForward`; `schema` (optional).
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
const { items, lastEvaluatedKey } = await listUsers({
|
|
107
|
+
documentClient,
|
|
108
|
+
tableName,
|
|
109
|
+
lookupList: 'USER#ACTIVE'
|
|
110
|
+
});
|
|
111
|
+
// Pagination
|
|
112
|
+
const next = await listUsers({
|
|
113
|
+
documentClient,
|
|
114
|
+
tableName,
|
|
115
|
+
lookupList: 'USER#ACTIVE',
|
|
116
|
+
exclusiveStartKey: lastEvaluatedKey
|
|
117
|
+
});
|
|
118
|
+
// Search by name prefix (normalized)
|
|
119
|
+
const { items } = await listUsers({
|
|
120
|
+
documentClient,
|
|
121
|
+
tableName,
|
|
122
|
+
lookupList: 'USER#ACTIVE',
|
|
123
|
+
beginsWith: 'alice'
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## LOOKUP behavior (opt-out)
|
|
128
|
+
|
|
129
|
+
- **createUser:** Creates a LOOKUP item by default (`createLookupItem` defaults to `true`), so new users appear in the default list (e.g. `USER#ACTIVE`) and can be listed. Set `createLookupItem: false` to skip LOOKUP.
|
|
130
|
+
- **updateUser:** Updates the LOOKUP item by default (`updateLookupItem` defaults to `true`). Set `updateLookupItem: false` to skip LOOKUP.
|
|
131
|
+
- **listUsers:** Only returns items that match the LOOKUP pattern (same GSI, `pk1 = lookupList`). If you opt out of LOOKUP on create/update, you must implement your own listing (e.g. Scan or custom GSI).
|
|
132
|
+
|
|
133
|
+
## Schema
|
|
134
|
+
|
|
135
|
+
The library uses a default schema (exported as `DEFAULT_SCHEMA`). Override specific keys by passing `options.schema`; it is merged over the default.
|
|
136
|
+
|
|
137
|
+
| Key | Default | Description |
|
|
138
|
+
|-----|---------|-------------|
|
|
139
|
+
| `userItemPK` | `'pk'` | Base table partition key attribute name |
|
|
140
|
+
| `userItemSK` | `'sk'` | Base table sort key attribute name |
|
|
141
|
+
| `userItemPrefix` | `'U#'` | Prefix for user item `pk` |
|
|
142
|
+
| `userItemLookupPrefix` | `'email#'` | Prefix for user item GSI pk1 (email lookup) |
|
|
143
|
+
| `userItemSKValue` | `'A'` | User item `sk` value |
|
|
144
|
+
| `userItemLookupSKValue` | `'A'` | User item GSI sk1 value for email lookup |
|
|
145
|
+
| `lookupIndex` | `'gsi1'` | GSI name |
|
|
146
|
+
| `lookupIndexPK` | `'pk1'` | GSI partition key attribute |
|
|
147
|
+
| `lookupIndexSK` | `'sk1'` | GSI sort key attribute |
|
|
148
|
+
| `lookupItemSK` | `'LOOKUP'` | LOOKUP item `sk` value |
|
|
149
|
+
| `defaultLookupList` | `'USER#ACTIVE'` | Default list for new users (LOOKUP pk1) |
|
|
150
|
+
| `emailItemPrefix` | `'EMAIL#'` | Email sentinel `pk` prefix |
|
|
151
|
+
| `emailItemUserAttribute` | `'userId'` | Attribute name for user ID on email/LOOKUP items |
|
|
152
|
+
|
|
153
|
+
Example: use `U#` for the email lookup key instead of `email#`:
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
const user = await getUserByEmail('alice@example.com', {
|
|
157
|
+
documentClient,
|
|
158
|
+
tableName,
|
|
159
|
+
schema: { userItemLookupPrefix: 'U#' }
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Example: full flow
|
|
164
|
+
|
|
165
|
+
```js
|
|
166
|
+
const { createUser, getUser, getUserByEmail, updateUser, listUsers } = require('@x-sls/dynamodb-users');
|
|
167
|
+
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
|
|
168
|
+
const { DynamoDBDocument } = require('@aws-sdk/lib-dynamodb');
|
|
169
|
+
|
|
170
|
+
const client = new DynamoDBClient({ region: 'us-east-1' });
|
|
171
|
+
const documentClient = DynamoDBDocument.from(client);
|
|
172
|
+
const tableName = process.env.USERS_TABLE;
|
|
173
|
+
|
|
174
|
+
const opts = { documentClient, tableName };
|
|
175
|
+
|
|
176
|
+
// Create (includes LOOKUP on USER#ACTIVE by default)
|
|
177
|
+
const user = await createUser({ email: 'alice@example.com', name: 'Alice' }, opts);
|
|
178
|
+
|
|
179
|
+
// Get by ID or email
|
|
180
|
+
const byId = await getUser(user.pk, opts);
|
|
181
|
+
const byEmail = await getUserByEmail('alice@example.com', opts);
|
|
182
|
+
|
|
183
|
+
// List active users
|
|
184
|
+
const { items } = await listUsers({ ...opts, lookupList: 'USER#ACTIVE' });
|
|
185
|
+
|
|
186
|
+
// Mark inactive
|
|
187
|
+
await updateUser(user.pk, {}, { ...opts, modifiedBy: 'system', modifiedReason: 'deactivate', lookupList: 'USER#INACTIVE' });
|
|
188
|
+
|
|
189
|
+
// List inactive users
|
|
190
|
+
const { items: inactive } = await listUsers({ ...opts, lookupList: 'USER#INACTIVE' });
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Tests
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
npm test
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Uses Node's built-in test runner (`node --test`).
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@x-sls/dynamodb-users",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "src/index.js",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --test src/__tests__/*.js"
|
|
7
|
+
},
|
|
8
|
+
"peerDependencies": {
|
|
9
|
+
"@aws-sdk/lib-dynamodb": "^3.1006.0"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@aws-sdk/lib-dynamodb": "^3.1006.0"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
const { describe, it } = require('node:test');
|
|
2
|
+
const assert = require('node:assert');
|
|
3
|
+
const {
|
|
4
|
+
DEFAULT_SCHEMA,
|
|
5
|
+
getUser,
|
|
6
|
+
getUserByEmail,
|
|
7
|
+
createUser,
|
|
8
|
+
updateUser
|
|
9
|
+
} = require('../index.js');
|
|
10
|
+
|
|
11
|
+
function createMockDocClient(returnValues) {
|
|
12
|
+
const queue = Array.isArray(returnValues) ? [...returnValues] : [returnValues];
|
|
13
|
+
return {
|
|
14
|
+
send: async () => {
|
|
15
|
+
const next = queue.shift();
|
|
16
|
+
if (next instanceof Error) throw next;
|
|
17
|
+
return next;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TABLE = 'test-table';
|
|
23
|
+
const baseOptions = { documentClient: null, tableName: TABLE };
|
|
24
|
+
|
|
25
|
+
describe('getUser', () => {
|
|
26
|
+
it('returns null when userId is falsy', async () => {
|
|
27
|
+
const doc = createMockDocClient({ Item: null });
|
|
28
|
+
assert.strictEqual(await getUser('', { ...baseOptions, documentClient: doc }), null);
|
|
29
|
+
assert.strictEqual(await getUser(null, { ...baseOptions, documentClient: doc }), null);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('throws when documentClient or tableName is missing', async () => {
|
|
33
|
+
const doc = createMockDocClient({ Item: null });
|
|
34
|
+
await assert.rejects(() => getUser('user-1', {}), /getUser requires options.documentClient and options.tableName/);
|
|
35
|
+
await assert.rejects(() => getUser('user-1', { documentClient: doc }), /getUser requires options.documentClient and options.tableName/);
|
|
36
|
+
await assert.rejects(() => getUser('user-1', { tableName: TABLE }), /getUser requires options.documentClient and options.tableName/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns null when item is not found', async () => {
|
|
40
|
+
const doc = createMockDocClient({ Item: null });
|
|
41
|
+
const result = await getUser('user-1', { ...baseOptions, documentClient: doc });
|
|
42
|
+
assert.strictEqual(result, null);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns item when found (userId without prefix)', async () => {
|
|
46
|
+
const item = { pk: 'U#user-1', sk: 'A', name: 'Test', email: 'test@example.com' };
|
|
47
|
+
const doc = createMockDocClient({ Item: item });
|
|
48
|
+
const result = await getUser('user-1', { ...baseOptions, documentClient: doc });
|
|
49
|
+
assert.deepStrictEqual(result, item);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns item when found (userId with U# prefix)', async () => {
|
|
53
|
+
const item = { pk: 'U#user-1', sk: 'A', name: 'Test' };
|
|
54
|
+
const doc = createMockDocClient({ Item: item });
|
|
55
|
+
const result = await getUser('U#user-1', { ...baseOptions, documentClient: doc });
|
|
56
|
+
assert.deepStrictEqual(result, item);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('coerces userId to string', async () => {
|
|
60
|
+
const item = { pk: 'U#123', sk: 'A' };
|
|
61
|
+
const doc = createMockDocClient({ Item: item });
|
|
62
|
+
const result = await getUser(123, { ...baseOptions, documentClient: doc });
|
|
63
|
+
assert.deepStrictEqual(result, item);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('getUserByEmail', () => {
|
|
68
|
+
it('returns null when email is falsy', async () => {
|
|
69
|
+
const doc = createMockDocClient({ Items: [] });
|
|
70
|
+
assert.strictEqual(await getUserByEmail('', { ...baseOptions, documentClient: doc, schema: { ...DEFAULT_SCHEMA } }), null);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('throws when documentClient, tableName, or schema.lookupIndex missing', async () => {
|
|
74
|
+
const doc = createMockDocClient({ Items: [] });
|
|
75
|
+
await assert.rejects(() => getUserByEmail('a@b.com', {}), /getUserByEmail requires options.documentClient and options.tableName/);
|
|
76
|
+
await assert.rejects(() => getUserByEmail('a@b.com', { ...baseOptions, documentClient: doc, schema: { ...DEFAULT_SCHEMA, lookupIndex: '' } }), /getUserByEmail requires schema.lookupIndex/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns first item from index when GSI has results', async () => {
|
|
80
|
+
const item = { pk: 'U#user-1', sk: 'A', pk1: 'email#a@b.com', sk1: 'A', name: 'Test' };
|
|
81
|
+
const doc = createMockDocClient({ Items: [item] });
|
|
82
|
+
const result = await getUserByEmail('a@b.com', { ...baseOptions, documentClient: doc });
|
|
83
|
+
assert.deepStrictEqual(result, item);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('uses schema override for lookup prefix (userItemLookupPrefix)', async () => {
|
|
87
|
+
const item = { pk: 'U#user-1', sk: 'A', pk1: 'U#a@b.com', sk1: 'A', name: 'Test' };
|
|
88
|
+
let capturedQuery;
|
|
89
|
+
const doc = createMockDocClient({ Items: [item] });
|
|
90
|
+
const origSend = doc.send.bind(doc);
|
|
91
|
+
doc.send = async (cmd) => {
|
|
92
|
+
if (cmd.input?.KeyConditionExpression) capturedQuery = { names: cmd.input.ExpressionAttributeNames, values: cmd.input.ExpressionAttributeValues };
|
|
93
|
+
return origSend(cmd);
|
|
94
|
+
};
|
|
95
|
+
const result = await getUserByEmail('a@b.com', { ...baseOptions, documentClient: doc, schema: { userItemLookupPrefix: 'U#' } });
|
|
96
|
+
assert.deepStrictEqual(result, item);
|
|
97
|
+
assert.strictEqual(capturedQuery?.values?.[':pk1'], 'U#a@b.com');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('falls back to email sentinel then getUser when GSI is empty', async () => {
|
|
101
|
+
const userItem = { pk: 'U#user-1', sk: 'A', name: 'Test', email: 'a@b.com' };
|
|
102
|
+
const doc = createMockDocClient([
|
|
103
|
+
{ Items: [] },
|
|
104
|
+
{ Item: { [DEFAULT_SCHEMA.userItemPK]: 'EMAIL#a@b.com', [DEFAULT_SCHEMA.userItemSK]: 'A', [DEFAULT_SCHEMA.emailItemUserAttribute]: 'user-1' } },
|
|
105
|
+
{ Item: userItem }
|
|
106
|
+
]);
|
|
107
|
+
const result = await getUserByEmail('a@b.com', { ...baseOptions, documentClient: doc });
|
|
108
|
+
assert.deepStrictEqual(result, userItem);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns null when GSI empty and no email sentinel', async () => {
|
|
112
|
+
const doc = createMockDocClient([{ Items: [] }, { Item: null }]);
|
|
113
|
+
const result = await getUserByEmail('missing@b.com', { ...baseOptions, documentClient: doc });
|
|
114
|
+
assert.strictEqual(result, null);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('createUser', () => {
|
|
119
|
+
it('throws when email or name is missing', async () => {
|
|
120
|
+
const doc = createMockDocClient(undefined);
|
|
121
|
+
await assert.rejects(() => createUser({ name: 'Test' }, { ...baseOptions, documentClient: doc }), /Email is required/);
|
|
122
|
+
await assert.rejects(() => createUser({ email: 'a@b.com' }, { ...baseOptions, documentClient: doc }), /Name is required/);
|
|
123
|
+
await assert.rejects(() => createUser({ email: ' ', name: 'Test' }, { ...baseOptions, documentClient: doc }), /Email is required/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('throws when documentClient or tableName is missing', async () => {
|
|
127
|
+
await assert.rejects(() => createUser({ email: 'a@b.com', name: 'Test' }, {}), /createUser requires options.documentClient and options.tableName/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('sends TransactWrite with user A, email item, and default LOOKUP item (USER#ACTIVE), returns userA', async () => {
|
|
131
|
+
let capturedTransactItems;
|
|
132
|
+
const doc = createMockDocClient(undefined);
|
|
133
|
+
doc.send = async (cmd) => {
|
|
134
|
+
capturedTransactItems = cmd.input?.TransactItems;
|
|
135
|
+
return undefined;
|
|
136
|
+
};
|
|
137
|
+
const result = await createUser({ email: 'a@b.com', name: 'Test' }, { ...baseOptions, documentClient: doc });
|
|
138
|
+
assert.ok(Array.isArray(capturedTransactItems));
|
|
139
|
+
assert.strictEqual(capturedTransactItems.length, 3);
|
|
140
|
+
assert.strictEqual(capturedTransactItems[0].Put.Item.type, DEFAULT_SCHEMA.userItemType);
|
|
141
|
+
assert.strictEqual(capturedTransactItems[1].Put.Item.type, DEFAULT_SCHEMA.emailItemType);
|
|
142
|
+
const lookupItem = capturedTransactItems[2].Put.Item;
|
|
143
|
+
assert.strictEqual(lookupItem.type, DEFAULT_SCHEMA.lookupItemType);
|
|
144
|
+
assert.strictEqual(lookupItem.sk, DEFAULT_SCHEMA.lookupItemSK);
|
|
145
|
+
assert.strictEqual(lookupItem.pk1, 'USER#ACTIVE');
|
|
146
|
+
assert.strictEqual(lookupItem.sk1, 'test');
|
|
147
|
+
assert.ok(result.pk.startsWith('U#'));
|
|
148
|
+
assert.strictEqual(result.sk, 'A');
|
|
149
|
+
assert.strictEqual(result.email, 'a@b.com');
|
|
150
|
+
assert.strictEqual(result.name, 'Test');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('when createLookupItem false, sends only user A and email item', async () => {
|
|
154
|
+
let capturedTransactItems;
|
|
155
|
+
const doc = createMockDocClient(undefined);
|
|
156
|
+
doc.send = async (cmd) => {
|
|
157
|
+
capturedTransactItems = cmd.input?.TransactItems;
|
|
158
|
+
return undefined;
|
|
159
|
+
};
|
|
160
|
+
await createUser({ email: 'a@b.com', name: 'Test' }, { ...baseOptions, documentClient: doc, createLookupItem: false });
|
|
161
|
+
assert.strictEqual(capturedTransactItems.length, 2);
|
|
162
|
+
assert.strictEqual(capturedTransactItems[0].Put.Item.type, DEFAULT_SCHEMA.userItemType);
|
|
163
|
+
assert.strictEqual(capturedTransactItems[1].Put.Item.type, DEFAULT_SCHEMA.emailItemType);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('throws User with this email already exists on TransactionCanceledException', async () => {
|
|
167
|
+
const doc = createMockDocClient(Object.assign(new Error(), { name: 'TransactionCanceledException' }));
|
|
168
|
+
await assert.rejects(() => createUser({ email: 'a@b.com', name: 'Test' }, { ...baseOptions, documentClient: doc }), /User with this email already exists/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('uses schema override for lookup prefix (userItemLookupPrefix) in written user item', async () => {
|
|
172
|
+
let capturedUserItem;
|
|
173
|
+
const doc = createMockDocClient(undefined);
|
|
174
|
+
doc.send = async (cmd) => {
|
|
175
|
+
const items = cmd.input?.TransactItems;
|
|
176
|
+
if (Array.isArray(items) && items[0]?.Put?.Item) capturedUserItem = items[0].Put.Item;
|
|
177
|
+
return undefined;
|
|
178
|
+
};
|
|
179
|
+
await createUser({ email: 'a@b.com', name: 'Test' }, { ...baseOptions, documentClient: doc, schema: { userItemLookupPrefix: 'U#' } });
|
|
180
|
+
assert.ok(capturedUserItem);
|
|
181
|
+
assert.strictEqual(capturedUserItem.pk1, 'U#a@b.com');
|
|
182
|
+
assert.ok(capturedUserItem.pk.startsWith('U#'));
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('updateUser', () => {
|
|
187
|
+
const currentUser = { pk: 'U#user-1', sk: 'A', name: 'Old', email: 'old@b.com', modifiedAt: 'x', modifiedBy: 'x', modifiedReason: 'x' };
|
|
188
|
+
|
|
189
|
+
it('throws when documentClient, tableName, modifiedBy or modifiedReason missing', async () => {
|
|
190
|
+
const doc = createMockDocClient({ Item: currentUser });
|
|
191
|
+
await assert.rejects(() => updateUser('user-1', { name: 'New' }, {}), /updateUser requires options.documentClient and options.tableName/);
|
|
192
|
+
await assert.rejects(() => updateUser('user-1', { name: 'New' }, { ...baseOptions, documentClient: doc }), /updateUser requires options.modifiedBy and options.modifiedReason/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('throws when user not found', async () => {
|
|
196
|
+
const doc = createMockDocClient({ Item: null });
|
|
197
|
+
await assert.rejects(() => updateUser('user-1', { name: 'New' }, { ...baseOptions, documentClient: doc, modifiedBy: 'u', modifiedReason: 'r' }), /User not found/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('sends Update and returns updatedUser shape', async () => {
|
|
201
|
+
let callCount = 0;
|
|
202
|
+
let transactItems;
|
|
203
|
+
const doc = {
|
|
204
|
+
send: async (cmd) => {
|
|
205
|
+
if (cmd.input?.TransactItems) transactItems = cmd.input.TransactItems;
|
|
206
|
+
return callCount++ === 0 ? { Item: currentUser } : undefined;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const result = await updateUser('user-1', { name: 'New Name' }, { ...baseOptions, documentClient: doc, modifiedBy: 'u', modifiedReason: 'r' });
|
|
210
|
+
assert.strictEqual(result.name, 'New Name');
|
|
211
|
+
assert.strictEqual(result.email, 'old@b.com');
|
|
212
|
+
assert.ok(transactItems.some((t) => t.Update));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('when email change: includes pk1/sk1 in Update and Delete + Put sentinels', async () => {
|
|
216
|
+
let callCount = 0;
|
|
217
|
+
let transactItems;
|
|
218
|
+
const doc = {
|
|
219
|
+
send: async (cmd) => {
|
|
220
|
+
if (cmd.input?.TransactItems) transactItems = cmd.input.TransactItems;
|
|
221
|
+
return callCount++ === 0 ? { Item: currentUser } : undefined;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
await updateUser('user-1', { email: 'new@b.com' }, { ...baseOptions, documentClient: doc, modifiedBy: 'u', modifiedReason: 'r' });
|
|
225
|
+
const updateOp = transactItems.find((t) => t.Update);
|
|
226
|
+
assert.ok(updateOp?.Update?.ExpressionAttributeValues?.[':pk1']?.includes('new@b.com'));
|
|
227
|
+
const deleteOp = transactItems.find((t) => t.Delete);
|
|
228
|
+
const putOp = transactItems.find((t) => t.Put && t.Put.Item?.[DEFAULT_SCHEMA.emailItemUserAttribute]);
|
|
229
|
+
assert.ok(deleteOp?.Delete?.Key?.pk?.includes('old@b.com'));
|
|
230
|
+
assert.ok(putOp?.Put?.Item?.pk?.includes('new@b.com'));
|
|
231
|
+
assert.strictEqual(putOp.Put.Item[DEFAULT_SCHEMA.emailItemUserAttribute], 'user-1');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('throws User with this email already exists when transaction fails during email change', async () => {
|
|
235
|
+
const doc = createMockDocClient([{ Item: currentUser }, Object.assign(new Error(), { name: 'TransactionCanceledException' })]);
|
|
236
|
+
await assert.rejects(() => updateUser('user-1', { email: 'taken@b.com' }, { ...baseOptions, documentClient: doc, modifiedBy: 'u', modifiedReason: 'r' }), /User with this email already exists/);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('DEFAULT_SCHEMA', () => {
|
|
241
|
+
it('has expected keys', () => {
|
|
242
|
+
assert.strictEqual(DEFAULT_SCHEMA.userItemPK, 'pk');
|
|
243
|
+
assert.strictEqual(DEFAULT_SCHEMA.userItemSK, 'sk');
|
|
244
|
+
assert.strictEqual(DEFAULT_SCHEMA.userItemPrefix, 'U#');
|
|
245
|
+
assert.strictEqual(DEFAULT_SCHEMA.userItemLookupPrefix, 'email#');
|
|
246
|
+
assert.strictEqual(DEFAULT_SCHEMA.defaultLookupList, 'USER#ACTIVE');
|
|
247
|
+
assert.strictEqual(DEFAULT_SCHEMA.emailItemPrefix, 'EMAIL#');
|
|
248
|
+
assert.strictEqual(DEFAULT_SCHEMA.emailItemUserAttribute, 'userId');
|
|
249
|
+
assert.ok(Array.isArray(DEFAULT_SCHEMA.purgeVersionAttributes));
|
|
250
|
+
});
|
|
251
|
+
});
|
package/src/index.js
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for managing users in DynamoDB.
|
|
3
|
+
*
|
|
4
|
+
* @schema (DEFAULT_SCHEMA)
|
|
5
|
+
* - User item: pk=U#<uuid>, sk=A, pk1=email#<normalized>, sk1=A. Get by id: GetItem(pk, sk). Get by email: query lookup index.
|
|
6
|
+
* - Email item: pk=EMAIL#<email>, sk=A, userId. Fallback for by-email when index is eventually consistent.
|
|
7
|
+
* - Optional LOOKUP item (when createLookupItem/updateLookupItem): pk=U#<id>, sk=LOOKUP, pk1/sk1 for lookup list index. Out-of-the-box default is defaultLookupList (e.g. USER#ACTIVE); override via options.lookupList. sk1 is always normalized name (enables begins_with search via listUsers beginsWith). Default projection: defaultLookupAttributes (email, name, picture); extend via lookupExtraAttributes. listUsers({ lookupList }) queries this index; optional beginsWith, limit, exclusiveStartKey, scanIndexForward.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { randomUUID } = require('node:crypto');
|
|
11
|
+
const { GetCommand, QueryCommand, TransactWriteCommand } = require('@aws-sdk/lib-dynamodb');
|
|
12
|
+
|
|
13
|
+
const DEFAULT_SCHEMA = {
|
|
14
|
+
userItemPK: 'pk',
|
|
15
|
+
userItemSK: 'sk',
|
|
16
|
+
userItemPrefix: 'U#',
|
|
17
|
+
userItemLookupPrefix: 'email#',
|
|
18
|
+
userItemSKValue: 'A',
|
|
19
|
+
userItemLookupSKValue: 'A',
|
|
20
|
+
userItemVersionPrefix: 'v#',
|
|
21
|
+
lookupIndex: 'gsi1',
|
|
22
|
+
lookupIndexPK: 'pk1',
|
|
23
|
+
lookupIndexSK: 'sk1',
|
|
24
|
+
lookupItemSK: 'LOOKUP',
|
|
25
|
+
lookupItemType: 'user_lookup',
|
|
26
|
+
defaultLookupList: 'USER#ACTIVE',
|
|
27
|
+
defaultLookupAttributes: ['email', 'name', 'picture'],
|
|
28
|
+
emailItemPrefix: 'EMAIL#',
|
|
29
|
+
emailItemSKValue: 'A',
|
|
30
|
+
emailItemUserAttribute: 'userId',
|
|
31
|
+
userItemType: 'user',
|
|
32
|
+
userItemVersionType: 'user_version',
|
|
33
|
+
emailItemType: 'email',
|
|
34
|
+
purgeVersionAttributes: ['pk1', 'sk1', 'pk2', 'sk2', 'pk3', 'sk3']
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function getSchema(options) {
|
|
38
|
+
return { ...DEFAULT_SCHEMA, ...(options?.schema) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeString(text) {
|
|
42
|
+
return String(text).toLowerCase().trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stripUserPrefix(pk, prefix) {
|
|
46
|
+
return pk && typeof pk === 'string' && pk.startsWith(prefix) ? pk.slice(prefix.length) : pk;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build LOOKUP item for list queries on GSI1. Shallow projection from user record plus list keys.
|
|
51
|
+
* @param {object} schema - Resolved schema (getSchema(options)).
|
|
52
|
+
* @param {object} userRecord - User A record (or merged) to project from.
|
|
53
|
+
* @param {string} lookupList - Lookup list key for GSI1 partition (e.g. USER#ACTIVE).
|
|
54
|
+
* @param {string} sk1Value - GSI1 sort key value (normalized name, computed by caller).
|
|
55
|
+
* @param {string[]} [extraLookupAttributes] - Additional attribute names to copy from userRecord onto LOOKUP.
|
|
56
|
+
* @returns {object} Item suitable for Put (same table).
|
|
57
|
+
*/
|
|
58
|
+
function buildLookupItem(schema, userRecord, lookupList, sk1Value, extraLookupAttributes = []) {
|
|
59
|
+
const pk = userRecord[schema.userItemPK];
|
|
60
|
+
const userId = stripUserPrefix(pk, schema.userItemPrefix);
|
|
61
|
+
const defaultAttrs = Array.isArray(schema.defaultLookupAttributes) ? schema.defaultLookupAttributes : ['email', 'name', 'picture'];
|
|
62
|
+
const extraAttrs = Array.isArray(extraLookupAttributes) ? extraLookupAttributes : [];
|
|
63
|
+
const attrs = [...new Set([...defaultAttrs, ...extraAttrs])];
|
|
64
|
+
|
|
65
|
+
const item = {
|
|
66
|
+
[schema.userItemPK]: pk,
|
|
67
|
+
[schema.userItemSK]: schema.lookupItemSK,
|
|
68
|
+
type: schema.lookupItemType,
|
|
69
|
+
[schema.lookupIndexPK]: lookupList,
|
|
70
|
+
[schema.lookupIndexSK]: sk1Value,
|
|
71
|
+
[schema.emailItemUserAttribute]: userId
|
|
72
|
+
};
|
|
73
|
+
for (const key of attrs) {
|
|
74
|
+
if (userRecord[key] !== undefined) item[key] = userRecord[key];
|
|
75
|
+
}
|
|
76
|
+
return item;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get user by ID. Access pattern: GetItem on base table.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} userId - User ID (with or without prefix).
|
|
83
|
+
* @param {object} options - documentClient, tableName (required); schema (optional, overrides defaults).
|
|
84
|
+
* @returns {Promise<object|null>} User item or null if not found.
|
|
85
|
+
*/
|
|
86
|
+
async function getUser(userId, options) {
|
|
87
|
+
if (!userId) return null;
|
|
88
|
+
const userIdStr = String(userId);
|
|
89
|
+
|
|
90
|
+
const { documentClient, tableName } = options ?? {};
|
|
91
|
+
if (!documentClient || !tableName) throw new Error('getUser requires options.documentClient and options.tableName');
|
|
92
|
+
|
|
93
|
+
const schema = getSchema(options);
|
|
94
|
+
const prefix = schema.userItemPrefix;
|
|
95
|
+
const pk = userIdStr.startsWith(prefix) ? userIdStr : `${prefix}${userIdStr}`;
|
|
96
|
+
|
|
97
|
+
const result = await documentClient.send(
|
|
98
|
+
new GetCommand({
|
|
99
|
+
TableName: tableName,
|
|
100
|
+
Key: { [schema.userItemPK]: pk, [schema.userItemSK]: schema.userItemSKValue }
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
if (!result.Item) return null;
|
|
104
|
+
return result.Item;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get user by email. Tries lookup index first; on empty, falls back to EMAIL sentinel + getUser.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} email - User email (normalized internally).
|
|
111
|
+
* @param {object} options - documentClient, tableName (required); schema (optional; schema.lookupIndex required for by-email lookup); normalizeEmailFn (optional).
|
|
112
|
+
* @returns {Promise<object|null>} User item or null if not found.
|
|
113
|
+
*/
|
|
114
|
+
async function getUserByEmail(email, options) {
|
|
115
|
+
|
|
116
|
+
if (!email) return null;
|
|
117
|
+
|
|
118
|
+
const { documentClient, tableName, normalizeEmailFn } = options ?? {};
|
|
119
|
+
if (!documentClient || !tableName) throw new Error('getUserByEmail requires options.documentClient and options.tableName');
|
|
120
|
+
|
|
121
|
+
const schema = getSchema(options);
|
|
122
|
+
if (!schema.lookupIndex) throw new Error('getUserByEmail requires schema.lookupIndex');
|
|
123
|
+
|
|
124
|
+
const normalize = normalizeEmailFn ?? normalizeString;
|
|
125
|
+
|
|
126
|
+
const normalized = normalize(email);
|
|
127
|
+
const pk1Val = `${schema.userItemLookupPrefix}${normalized}`;
|
|
128
|
+
const sk1Val = schema.userItemLookupSKValue;
|
|
129
|
+
|
|
130
|
+
// Try lookup index first.
|
|
131
|
+
const gsiResult = await documentClient.send(
|
|
132
|
+
new QueryCommand({
|
|
133
|
+
TableName: tableName,
|
|
134
|
+
IndexName: schema.lookupIndex,
|
|
135
|
+
KeyConditionExpression: '#pk1 = :pk1 AND #sk1 = :sk1',
|
|
136
|
+
ExpressionAttributeNames: { '#pk1': schema.lookupIndexPK, '#sk1': schema.lookupIndexSK },
|
|
137
|
+
ExpressionAttributeValues: { ':pk1': pk1Val, ':sk1': sk1Val }
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (gsiResult.Items?.length) {
|
|
142
|
+
return gsiResult.Items[0];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Fallback to email item.
|
|
146
|
+
const emailPk = `${schema.emailItemPrefix}${normalized}`;
|
|
147
|
+
const emailResult = await documentClient.send(
|
|
148
|
+
new GetCommand({
|
|
149
|
+
TableName: tableName,
|
|
150
|
+
Key: { [schema.userItemPK]: emailPk, [schema.userItemSK]: schema.emailItemSKValue }
|
|
151
|
+
})
|
|
152
|
+
);
|
|
153
|
+
const userId = emailResult.Item?.[schema.emailItemUserAttribute];
|
|
154
|
+
if (!userId) return null;
|
|
155
|
+
return getUser(userId, options);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* List users by querying the LOOKUP index (GSI1 by default). Uses pk1/sk1 from schema.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} options - documentClient, tableName, lookupList (required; e.g. 'USER#ACTIVE'); beginsWith (optional; begins_with on sk1, e.g. normalized name prefix), limit, exclusiveStartKey, scanIndexForward; schema (optional).
|
|
162
|
+
* @returns {Promise<{ items: object[], lastEvaluatedKey: object|null }>}
|
|
163
|
+
*/
|
|
164
|
+
async function listUsers(options = {}) {
|
|
165
|
+
const { documentClient, tableName, lookupList, beginsWith, limit = 50, exclusiveStartKey, scanIndexForward } = options;
|
|
166
|
+
if (!documentClient || !tableName) throw new Error('listUsers requires options.documentClient and options.tableName');
|
|
167
|
+
if (lookupList == null || lookupList === '') throw new Error('listUsers requires options.lookupList');
|
|
168
|
+
const schema = getSchema(options);
|
|
169
|
+
if (!schema.lookupIndex) throw new Error('listUsers requires schema.lookupIndex');
|
|
170
|
+
|
|
171
|
+
const cap = Math.min(Math.max(1, limit), 100);
|
|
172
|
+
let keyConditionExpression = `#pk1 = :pk1`;
|
|
173
|
+
const expressionAttributeNames = { '#pk1': schema.lookupIndexPK };
|
|
174
|
+
const expressionAttributeValues = { ':pk1': lookupList };
|
|
175
|
+
|
|
176
|
+
if (beginsWith != null && String(beginsWith).trim() !== '') {
|
|
177
|
+
keyConditionExpression += ' AND begins_with(#sk1, :beginsWith)';
|
|
178
|
+
expressionAttributeNames['#sk1'] = schema.lookupIndexSK;
|
|
179
|
+
expressionAttributeValues[':beginsWith'] = String(beginsWith).trim();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const result = await documentClient.send(
|
|
183
|
+
new QueryCommand({
|
|
184
|
+
TableName: tableName,
|
|
185
|
+
IndexName: schema.lookupIndex,
|
|
186
|
+
KeyConditionExpression: keyConditionExpression,
|
|
187
|
+
ExpressionAttributeNames: expressionAttributeNames,
|
|
188
|
+
ExpressionAttributeValues: expressionAttributeValues,
|
|
189
|
+
Limit: cap,
|
|
190
|
+
...(exclusiveStartKey != null && { ExclusiveStartKey: exclusiveStartKey }),
|
|
191
|
+
...(typeof scanIndexForward === 'boolean' && { ScanIndexForward: scanIndexForward })
|
|
192
|
+
})
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
items: result.Items ?? [],
|
|
197
|
+
lastEvaluatedKey: result.LastEvaluatedKey ?? null
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Create user atomically: A record and EMAIL sentinel. Optionally a LOOKUP item on GSI1 (when createLookupItem and list keys provided). Optionally add more items via options.extraTransactItems.
|
|
203
|
+
*
|
|
204
|
+
* @param {object} input - At least email, name (required). Other keys are spread onto the user A record (e.g. role, picture, scope, status).
|
|
205
|
+
* @param {object} options - documentClient, tableName (required); createLookupItem (default true), lookupList (optional), lookupExtraAttributes, extraTransactItems, normalizeEmailFn, schema (optional). When createLookupItem is true, the lookup list defaults to schema.defaultLookupList (e.g. USER#ACTIVE) unless options.lookupList is a non-empty string. LOOKUP sk1 is always the normalized name.
|
|
206
|
+
* @returns {Promise<object>} Created user A record shape. Throws on duplicate email.
|
|
207
|
+
*/
|
|
208
|
+
async function createUser(input, options = {}) {
|
|
209
|
+
const { documentClient, tableName, extraTransactItems, normalizeEmailFn, createLookupItem = true, lookupList, lookupExtraAttributes } = options;
|
|
210
|
+
if (!documentClient || !tableName) throw new Error('createUser requires options.documentClient and options.tableName');
|
|
211
|
+
const schema = getSchema(options);
|
|
212
|
+
|
|
213
|
+
const { email, name, ...rest } = input;
|
|
214
|
+
if (!email || !String(email).trim()) throw new Error('Email is required');
|
|
215
|
+
if (!name || !String(name).trim()) throw new Error('Name is required');
|
|
216
|
+
|
|
217
|
+
const normalize = normalizeEmailFn ?? normalizeString;
|
|
218
|
+
const userId = randomUUID();
|
|
219
|
+
const normalizedEmail = normalize(email);
|
|
220
|
+
const trimmedName = String(name).trim();
|
|
221
|
+
const now = new Date().toISOString();
|
|
222
|
+
|
|
223
|
+
const userA = {
|
|
224
|
+
[schema.userItemPK]: `${schema.userItemPrefix}${userId}`,
|
|
225
|
+
[schema.userItemSK]: schema.userItemSKValue,
|
|
226
|
+
type: schema.userItemType,
|
|
227
|
+
[schema.lookupIndexPK]: `${schema.userItemLookupPrefix}${normalizedEmail}`,
|
|
228
|
+
[schema.lookupIndexSK]: schema.userItemLookupSKValue,
|
|
229
|
+
name: trimmedName,
|
|
230
|
+
email: normalizedEmail,
|
|
231
|
+
createdAt: now,
|
|
232
|
+
modifiedAt: now,
|
|
233
|
+
modifiedBy: rest.modifiedBy ?? 'system',
|
|
234
|
+
modifiedReason: rest.modifiedReason ?? 'create',
|
|
235
|
+
...rest
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const emailItem = {
|
|
239
|
+
[schema.userItemPK]: `${schema.emailItemPrefix}${normalizedEmail}`,
|
|
240
|
+
[schema.userItemSK]: schema.emailItemSKValue,
|
|
241
|
+
type: schema.emailItemType,
|
|
242
|
+
[schema.emailItemUserAttribute]: userId
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const transactItems = [
|
|
246
|
+
{ Put: { TableName: tableName, Item: userA } },
|
|
247
|
+
{
|
|
248
|
+
Put: {
|
|
249
|
+
TableName: tableName,
|
|
250
|
+
Item: emailItem,
|
|
251
|
+
ConditionExpression: `attribute_not_exists(#pk)`,
|
|
252
|
+
ExpressionAttributeNames: { '#pk': schema.userItemPK }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
if (createLookupItem !== false && schema.lookupIndex) {
|
|
258
|
+
const listKey = (typeof lookupList === 'string' && lookupList.trim() !== '') ? lookupList.trim() : (schema.defaultLookupList ?? 'USER#ACTIVE');
|
|
259
|
+
const sk1 = normalizeString(trimmedName);
|
|
260
|
+
const lookupItem = buildLookupItem(schema, userA, listKey, sk1, lookupExtraAttributes);
|
|
261
|
+
transactItems.push({ Put: { TableName: tableName, Item: lookupItem } });
|
|
262
|
+
}
|
|
263
|
+
if (Array.isArray(extraTransactItems) && extraTransactItems.length) transactItems.push(...extraTransactItems);
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
await documentClient.send(new TransactWriteCommand({ TransactItems: transactItems }));
|
|
267
|
+
} catch (err) {
|
|
268
|
+
if (err.name === 'TransactionCanceledException') {
|
|
269
|
+
throw new Error('User with this email already exists');
|
|
270
|
+
}
|
|
271
|
+
throw err;
|
|
272
|
+
}
|
|
273
|
+
return userA;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Update user: put version record (v#), update A record, then any options.extraTransactItems (e.g. LOOKUP, BALANCE).
|
|
278
|
+
*
|
|
279
|
+
* @param {string} userId - User ID (with or without prefix).
|
|
280
|
+
* @param {object} updates - Fields to update. If options.allowedUpdateFields is omitted, all keys in updates are applied.
|
|
281
|
+
* @param {object} options - documentClient, tableName, modifiedBy, modifiedReason (required); createVersionRecord, extraTransactItems, allowedUpdateFields, updateLookupItem (default true), lookupList (optional), lookupExtraAttributes, schema (optional). LOOKUP sk1 is always the normalized name.
|
|
282
|
+
* @returns {Promise<object>} Updated user A record shape. Throws if user not found, or if email is changed to one that already exists (sentinel).
|
|
283
|
+
*/
|
|
284
|
+
async function updateUser(userId, updates, options = {}) {
|
|
285
|
+
const { documentClient, tableName, modifiedBy, modifiedReason, createVersionRecord = true, extraTransactItems, allowedUpdateFields, updateLookupItem = true, lookupList, lookupExtraAttributes } = options;
|
|
286
|
+
if (!documentClient || !tableName) throw new Error('updateUser requires options.documentClient and options.tableName');
|
|
287
|
+
if (modifiedBy == null || modifiedReason == null) {
|
|
288
|
+
throw new Error('updateUser requires options.modifiedBy and options.modifiedReason');
|
|
289
|
+
}
|
|
290
|
+
const schema = getSchema(options);
|
|
291
|
+
|
|
292
|
+
const currentUser = await getUser(userId, options);
|
|
293
|
+
if (!currentUser) throw new Error('User not found');
|
|
294
|
+
|
|
295
|
+
const allowed = Array.isArray(allowedUpdateFields) ? allowedUpdateFields : Object.keys(updates || {});
|
|
296
|
+
const updatedUser = { ...currentUser };
|
|
297
|
+
for (const key of allowed) {
|
|
298
|
+
if (updates[key] !== undefined) updatedUser[key] = updates[key];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const timestamp = new Date().toISOString();
|
|
302
|
+
updatedUser.modifiedAt = timestamp;
|
|
303
|
+
updatedUser.modifiedBy = modifiedBy;
|
|
304
|
+
updatedUser.modifiedReason = modifiedReason;
|
|
305
|
+
|
|
306
|
+
const normalize = options.normalizeEmailFn ?? normalizeString;
|
|
307
|
+
const emailAllowed = !Array.isArray(allowedUpdateFields) || allowedUpdateFields.includes('email');
|
|
308
|
+
const normalizedCurrent = normalize(currentUser.email ?? '');
|
|
309
|
+
const normalizedNew = normalize(updatedUser.email ?? '');
|
|
310
|
+
const isEmailChange = emailAllowed && normalizedNew !== '' && normalizedNew !== normalizedCurrent;
|
|
311
|
+
|
|
312
|
+
const emailChange = isEmailChange
|
|
313
|
+
? {
|
|
314
|
+
normalizedOld: normalizedCurrent,
|
|
315
|
+
normalizedNew,
|
|
316
|
+
userIdRaw: stripUserPrefix(currentUser[schema.userItemPK], schema.userItemPrefix)
|
|
317
|
+
}
|
|
318
|
+
: null;
|
|
319
|
+
|
|
320
|
+
const pkValue = currentUser[schema.userItemPK];
|
|
321
|
+
const vRecord = { ...currentUser, [schema.userItemSK]: `${schema.userItemVersionPrefix}${timestamp}`, type: schema.userItemVersionType };
|
|
322
|
+
const purgeAttrs = schema.purgeVersionAttributes;
|
|
323
|
+
if (Array.isArray(purgeAttrs)) {
|
|
324
|
+
for (const attr of purgeAttrs) {
|
|
325
|
+
delete vRecord[attr];
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const names = {};
|
|
330
|
+
const values = {};
|
|
331
|
+
const setParts = [];
|
|
332
|
+
names['#modifiedAt'] = 'modifiedAt';
|
|
333
|
+
values[':modifiedAt'] = timestamp;
|
|
334
|
+
names['#modifiedBy'] = 'modifiedBy';
|
|
335
|
+
values[':modifiedBy'] = modifiedBy;
|
|
336
|
+
names['#modifiedReason'] = 'modifiedReason';
|
|
337
|
+
values[':modifiedReason'] = modifiedReason;
|
|
338
|
+
setParts.push('#modifiedAt = :modifiedAt', '#modifiedBy = :modifiedBy', '#modifiedReason = :modifiedReason');
|
|
339
|
+
|
|
340
|
+
for (const key of allowed) {
|
|
341
|
+
if (updates[key] === undefined) continue;
|
|
342
|
+
names[`#${key}`] = key;
|
|
343
|
+
values[`:${key}`] = updatedUser[key];
|
|
344
|
+
setParts.push(`#${key} = :${key}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (emailChange) {
|
|
348
|
+
names[`#${schema.lookupIndexPK}`] = schema.lookupIndexPK;
|
|
349
|
+
names[`#${schema.lookupIndexSK}`] = schema.lookupIndexSK;
|
|
350
|
+
values[':pk1'] = `${schema.userItemLookupPrefix}${emailChange.normalizedNew}`;
|
|
351
|
+
values[':sk1'] = schema.userItemLookupSKValue;
|
|
352
|
+
setParts.push(`#${schema.lookupIndexPK} = :pk1`, `#${schema.lookupIndexSK} = :sk1`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const updateA = {
|
|
356
|
+
TableName: tableName,
|
|
357
|
+
Key: { [schema.userItemPK]: pkValue, [schema.userItemSK]: schema.userItemSKValue },
|
|
358
|
+
UpdateExpression: `SET ${setParts.join(', ')}`,
|
|
359
|
+
ExpressionAttributeNames: names,
|
|
360
|
+
ExpressionAttributeValues: values
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const transactItems = [{ Update: updateA }];
|
|
364
|
+
if (createVersionRecord) transactItems.unshift({ Put: { TableName: tableName, Item: vRecord } });
|
|
365
|
+
if (emailChange) {
|
|
366
|
+
transactItems.push(
|
|
367
|
+
{
|
|
368
|
+
Delete: {
|
|
369
|
+
TableName: tableName,
|
|
370
|
+
Key: { [schema.userItemPK]: `${schema.emailItemPrefix}${emailChange.normalizedOld}`, [schema.userItemSK]: schema.emailItemSKValue }
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
Put: {
|
|
375
|
+
TableName: tableName,
|
|
376
|
+
Item: {
|
|
377
|
+
[schema.userItemPK]: `${schema.emailItemPrefix}${emailChange.normalizedNew}`,
|
|
378
|
+
[schema.userItemSK]: schema.emailItemSKValue,
|
|
379
|
+
type: schema.emailItemType,
|
|
380
|
+
[schema.emailItemUserAttribute]: emailChange.userIdRaw
|
|
381
|
+
},
|
|
382
|
+
ConditionExpression: 'attribute_not_exists(#pk)',
|
|
383
|
+
ExpressionAttributeNames: { '#pk': schema.userItemPK }
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
if (updateLookupItem !== false && schema.lookupIndex) {
|
|
389
|
+
const listKey = (typeof lookupList === 'string' && lookupList.trim() !== '') ? lookupList.trim() : (schema.defaultLookupList ?? 'USER#ACTIVE');
|
|
390
|
+
const sk1 = normalizeString(String(updatedUser.name ?? '').trim());
|
|
391
|
+
const lookupItem = buildLookupItem(schema, updatedUser, listKey, sk1, lookupExtraAttributes);
|
|
392
|
+
transactItems.push({ Put: { TableName: tableName, Item: lookupItem } });
|
|
393
|
+
}
|
|
394
|
+
if (Array.isArray(extraTransactItems) && extraTransactItems.length) {
|
|
395
|
+
transactItems.push(...extraTransactItems);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
await documentClient.send(new TransactWriteCommand({ TransactItems: transactItems }));
|
|
400
|
+
} catch (err) {
|
|
401
|
+
if (err.name === 'TransactionCanceledException') {
|
|
402
|
+
throw new Error(emailChange ? 'User with this email already exists' : 'User update transaction failed');
|
|
403
|
+
}
|
|
404
|
+
throw err;
|
|
405
|
+
}
|
|
406
|
+
return { ...updatedUser };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
module.exports = {
|
|
410
|
+
DEFAULT_SCHEMA,
|
|
411
|
+
getUser,
|
|
412
|
+
getUserByEmail,
|
|
413
|
+
listUsers,
|
|
414
|
+
createUser,
|
|
415
|
+
updateUser
|
|
416
|
+
};
|