@unchainedshop/core-users 5.0.0-alpha.1 → 5.0.0-alpha.3

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.
@@ -1,36 +1,34 @@
1
- import { buildDbIndexes, isDocumentDBCompatModeEnabled, } from '@unchainedshop/mongodb';
1
+ import { buildDbIndexes, } from '@unchainedshop/mongodb';
2
2
  export const UsersCollection = async (db) => {
3
3
  const Users = db.collection('users');
4
- if (!isDocumentDBCompatModeEnabled()) {
5
- await buildDbIndexes(Users, [
6
- {
7
- index: {
8
- _id: 'text',
9
- username: 'text',
10
- 'emails.address': 'text',
11
- 'profile.displayName': 'text',
12
- 'lastBillingAddress.firstName': 'text',
13
- 'lastBillingAddress.lastName': 'text',
14
- 'lastBillingAddress.company': 'text',
15
- 'lastBillingAddress.addressLine': 'text',
16
- 'lastBillingAddress.addressLine2': 'text',
17
- },
18
- options: {
19
- weights: {
20
- _id: 9,
21
- 'emails.address': 7,
22
- 'profile.displayName': 5,
23
- 'lastBillingAddress.firstName': 3,
24
- 'lastBillingAddress.lastName': 3,
25
- 'lastBillingAddress.company': 1,
26
- 'lastBillingAddress.addressLine': 1,
27
- 'lastBillingAddress.addressLine2': 1,
28
- },
29
- name: 'user_fulltext_search',
4
+ await buildDbIndexes(Users, [
5
+ {
6
+ index: {
7
+ _id: 'text',
8
+ username: 'text',
9
+ 'emails.address': 'text',
10
+ 'profile.displayName': 'text',
11
+ 'lastBillingAddress.firstName': 'text',
12
+ 'lastBillingAddress.lastName': 'text',
13
+ 'lastBillingAddress.company': 'text',
14
+ 'lastBillingAddress.addressLine': 'text',
15
+ 'lastBillingAddress.addressLine2': 'text',
16
+ },
17
+ options: {
18
+ weights: {
19
+ _id: 9,
20
+ 'emails.address': 7,
21
+ 'profile.displayName': 5,
22
+ 'lastBillingAddress.firstName': 3,
23
+ 'lastBillingAddress.lastName': 3,
24
+ 'lastBillingAddress.company': 1,
25
+ 'lastBillingAddress.addressLine': 1,
26
+ 'lastBillingAddress.addressLine2': 1,
30
27
  },
28
+ name: 'user_fulltext_search',
31
29
  },
32
- ]);
33
- }
30
+ },
31
+ ]);
34
32
  await buildDbIndexes(Users, [
35
33
  {
36
34
  index: {
@@ -96,6 +94,30 @@ export const UsersCollection = async (db) => {
96
94
  sparse: true,
97
95
  },
98
96
  },
97
+ {
98
+ index: {
99
+ 'services.web3.verified': 1,
100
+ },
101
+ options: {
102
+ sparse: true,
103
+ },
104
+ },
105
+ {
106
+ index: {
107
+ tags: 1,
108
+ },
109
+ options: {
110
+ sparse: true,
111
+ },
112
+ },
113
+ {
114
+ index: {
115
+ 'lastLogin.timestamp': 1,
116
+ },
117
+ options: {
118
+ sparse: true,
119
+ },
120
+ },
99
121
  ]);
100
122
  return Users;
101
123
  };
@@ -4,6 +4,7 @@ export interface WebAuthnCredentialsCreationRequest {
4
4
  username: string;
5
5
  origin: string;
6
6
  factor: 'first' | 'second' | 'either';
7
+ created?: Date;
7
8
  }
8
9
  type Collection = WebAuthnCredentialsCreationRequest & {
9
10
  _id: string;
@@ -2,11 +2,8 @@ import { buildDbIndexes } from '@unchainedshop/mongodb';
2
2
  export const WebAuthnCredentialsCreationRequestsCollection = async (db) => {
3
3
  const WebAuthnCredentialsCreationRequests = db.collection('accounts_webauthn_credentials_creation_requests');
4
4
  await buildDbIndexes(WebAuthnCredentialsCreationRequests, [
5
- {
6
- index: {
7
- username: 1,
8
- },
9
- },
5
+ { index: { username: 1 } },
6
+ { index: { created: 1 }, options: { expireAfterSeconds: 15 * 60 } },
10
7
  ]);
11
8
  return WebAuthnCredentialsCreationRequests;
12
9
  };
@@ -0,0 +1,2 @@
1
+ import type { MigrationRepository } from '@unchainedshop/mongodb';
2
+ export default function normalizeProfilePhone(repository: MigrationRepository): void;
@@ -0,0 +1,52 @@
1
+ import { normalizePhoneNumber } from '@unchainedshop/utils';
2
+ import { UsersCollection } from "../db/UsersCollection.js";
3
+ export default function normalizeProfilePhone(repository) {
4
+ repository?.register({
5
+ id: 20260625120200,
6
+ name: 'Normalize user.profile.phoneMobile and user.lastContact.telNumber to E.164 format',
7
+ up: async ({ logger }) => {
8
+ const Users = await UsersCollection(repository.db);
9
+ const users = await Users.find({
10
+ $or: [
11
+ { 'profile.phoneMobile': { $exists: true, $nin: [null, ''] } },
12
+ { 'lastContact.telNumber': { $exists: true, $nin: [null, ''] } },
13
+ ],
14
+ }, {
15
+ projection: {
16
+ _id: true,
17
+ profile: true,
18
+ lastContact: true,
19
+ lastBillingAddress: true,
20
+ lastLogin: true,
21
+ },
22
+ }).toArray();
23
+ let changed = 0;
24
+ let skipped = 0;
25
+ for (const user of users) {
26
+ const defaultCountry = user.lastBillingAddress?.countryCode ||
27
+ user.profile?.address?.countryCode ||
28
+ user.lastLogin?.countryCode;
29
+ const $set = {};
30
+ const phoneMobile = user.profile?.phoneMobile;
31
+ const normalizedPhoneMobile = normalizePhoneNumber(phoneMobile, defaultCountry);
32
+ if (normalizedPhoneMobile && normalizedPhoneMobile !== phoneMobile) {
33
+ $set['profile.phoneMobile'] = normalizedPhoneMobile;
34
+ }
35
+ const telNumber = user.lastContact?.telNumber;
36
+ const normalizedTelNumber = normalizePhoneNumber(telNumber, defaultCountry);
37
+ if (normalizedTelNumber && normalizedTelNumber !== telNumber) {
38
+ $set['lastContact.telNumber'] = normalizedTelNumber;
39
+ }
40
+ if (Object.keys($set).length === 0) {
41
+ if ((phoneMobile && !normalizedPhoneMobile) || (telNumber && !normalizedTelNumber)) {
42
+ skipped += 1;
43
+ }
44
+ continue;
45
+ }
46
+ await Users.updateOne({ _id: user._id }, { $set });
47
+ changed += 1;
48
+ }
49
+ logger?.info(`Normalize user phone numbers: ${changed} updated, ${skipped} left unchanged (unparseable)`);
50
+ },
51
+ });
52
+ }
@@ -64,6 +64,12 @@ export declare const configureUsersModule: (moduleInput: ModuleInput<UserSetting
64
64
  limit?: number;
65
65
  offset?: number;
66
66
  }): Promise<User[]>;
67
+ findGuestUserIds({ before }: {
68
+ before: Date;
69
+ }): Promise<string[]>;
70
+ findExistingUserIds({ userIds }: {
71
+ userIds: string[];
72
+ }): Promise<string[]>;
67
73
  userExists({ userId }: {
68
74
  userId: string;
69
75
  }): Promise<boolean>;
@@ -1,13 +1,14 @@
1
1
  import * as bcrypt from 'bcryptjs';
2
- import { generateDbFilterById, buildSortOptions, generateDbObjectId, insensitiveTrimmedRegexOperator, assertDocumentDBCompatMode, } from '@unchainedshop/mongodb';
2
+ import { generateDbFilterById, buildSortOptions, generateDbObjectId, insensitiveTrimmedRegexOperator, } from '@unchainedshop/mongodb';
3
3
  import { UsersCollection, } from "../db/UsersCollection.js";
4
4
  import { emit, registerEvents } from '@unchainedshop/events';
5
- import { systemLocale, SortDirection, sha256 } from '@unchainedshop/utils';
5
+ import { systemLocale, SortDirection, sha256, normalizePhoneNumber, } from '@unchainedshop/utils';
6
6
  import { UserAccountAction, userSettings, } from "../users-settings.js";
7
7
  import { configureUsersWebAuthnModule } from "./configureUsersWebAuthnModule.js";
8
8
  import * as pbkdf2 from "./pbkdf2.js";
9
9
  import { verifyWeb3Signature } from "../utils/web3-verification.js";
10
10
  import convertUserLocale from "../migrations/20241218092300-convert-locale.js";
11
+ import normalizeProfilePhone from "../migrations/20260625120200-normalize-profile-phone.js";
11
12
  const USER_EVENTS = [
12
13
  'USER_ACCOUNT_ACTION',
13
14
  'USER_CREATE',
@@ -75,7 +76,6 @@ export const buildFindSelector = ({ includeGuests, includeDeleted, queryString,
75
76
  selector['lastLogin.timestamp'].$gte = new Date(lastLogin.start);
76
77
  }
77
78
  if (queryString) {
78
- assertDocumentDBCompatMode();
79
79
  selector.$text = { $search: queryString };
80
80
  }
81
81
  return selector;
@@ -83,6 +83,7 @@ export const buildFindSelector = ({ includeGuests, includeDeleted, queryString,
83
83
  export const configureUsersModule = async (moduleInput) => {
84
84
  const { db, options, migrationRepository } = moduleInput;
85
85
  convertUserLocale(migrationRepository);
86
+ normalizeProfilePhone(migrationRepository);
86
87
  userSettings.configureSettings(options || {}, db);
87
88
  registerEvents(USER_EVENTS);
88
89
  const Users = await UsersCollection(db);
@@ -198,6 +199,23 @@ export const configureUsersModule = async (moduleInput) => {
198
199
  sort: buildSortOptions(query.sort || defaultSort),
199
200
  }).toArray();
200
201
  },
202
+ async findGuestUserIds({ before }) {
203
+ const users = await Users.find({
204
+ guest: true,
205
+ deleted: null,
206
+ created: { $lte: before },
207
+ $or: [
208
+ { 'lastLogin.timestamp': { $exists: false } },
209
+ { 'lastLogin.timestamp': { $lte: before } },
210
+ ],
211
+ }, { projection: { _id: 1 } }).toArray();
212
+ return users.map((u) => u._id);
213
+ },
214
+ async findExistingUserIds({ userIds }) {
215
+ if (!userIds.length)
216
+ return [];
217
+ return Users.distinct('_id', { _id: { $in: userIds } });
218
+ },
201
219
  async userExists({ userId }) {
202
220
  const userCount = await Users.countDocuments({ _id: userId, deleted: null }, { limit: 1 });
203
221
  return userCount === 1;
@@ -624,11 +642,28 @@ export const configureUsersModule = async (moduleInput) => {
624
642
  return Users.findOne(userFilter, {});
625
643
  }
626
644
  const modifier = { $set: {} };
627
- if (profile) {
628
- modifier.$set = Object.keys(profile).reduce((acc, profileKey) => {
645
+ let normalizedProfile = profile;
646
+ if (profile?.phoneMobile) {
647
+ let defaultCountry = profile.address?.countryCode;
648
+ if (!defaultCountry) {
649
+ const existing = await Users.findOne(userFilter, {
650
+ projection: { lastBillingAddress: true, profile: true, lastLogin: true },
651
+ });
652
+ defaultCountry =
653
+ existing?.lastBillingAddress?.countryCode ||
654
+ existing?.profile?.address?.countryCode ||
655
+ existing?.lastLogin?.countryCode;
656
+ }
657
+ normalizedProfile = {
658
+ ...profile,
659
+ phoneMobile: normalizePhoneNumber(profile.phoneMobile, defaultCountry) || profile.phoneMobile,
660
+ };
661
+ }
662
+ if (normalizedProfile) {
663
+ modifier.$set = Object.keys(normalizedProfile).reduce((acc, profileKey) => {
629
664
  return {
630
665
  ...acc,
631
- [`profile.${profileKey}`]: profile[profileKey],
666
+ [`profile.${profileKey}`]: normalizedProfile[profileKey],
632
667
  };
633
668
  }, {});
634
669
  }
@@ -685,14 +720,23 @@ export const configureUsersModule = async (moduleInput) => {
685
720
  return null;
686
721
  const profile = user.profile || {};
687
722
  const isGuest = !!user.guest;
723
+ const defaultCountry = user.lastBillingAddress?.countryCode ||
724
+ profile.address?.countryCode ||
725
+ user.lastLogin?.countryCode;
726
+ const normalizedContact = lastContact?.telNumber
727
+ ? {
728
+ ...lastContact,
729
+ telNumber: normalizePhoneNumber(lastContact.telNumber, defaultCountry) || lastContact.telNumber,
730
+ }
731
+ : lastContact;
688
732
  const modifier = {
689
733
  $set: {
690
734
  updated: new Date(),
691
- lastContact,
735
+ lastContact: normalizedContact,
692
736
  },
693
737
  };
694
- if ((!profile.phoneMobile || isGuest) && lastContact.telNumber) {
695
- modifier.$set['profile.phoneMobile'] = lastContact.telNumber;
738
+ if ((!profile.phoneMobile || isGuest) && normalizedContact.telNumber) {
739
+ modifier.$set['profile.phoneMobile'] = normalizedContact.telNumber;
696
740
  }
697
741
  const updatedUser = await Users.findOneAndUpdate(userFilter, modifier, {
698
742
  returnDocument: 'after',
@@ -36,7 +36,7 @@ async function fetchMDSEntriesImpl() {
36
36
  }
37
37
  return cache;
38
38
  }
39
- const fetchMDSEntries = memoizeWithTTL(fetchMDSEntriesImpl, ONE_DAY_MS);
39
+ const fetchMDSEntries = memoizeWithTTL(fetchMDSEntriesImpl, { ttl: ONE_DAY_MS });
40
40
  export function toArrayBuffer(buffer) {
41
41
  return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
42
42
  }
@@ -64,6 +64,7 @@ export const configureUsersWebAuthnModule = async ({ db }) => {
64
64
  origin,
65
65
  factor: 'either',
66
66
  username,
67
+ created: new Date(),
67
68
  });
68
69
  return {
69
70
  challenge,
@@ -96,6 +97,7 @@ export const configureUsersWebAuthnModule = async ({ db }) => {
96
97
  origin,
97
98
  factor: 'either',
98
99
  username,
100
+ created: new Date(),
99
101
  });
100
102
  return {
101
103
  challenge,
@@ -16,6 +16,7 @@ export type UserAccountAction = (typeof UserAccountAction)[keyof typeof UserAcco
16
16
  export interface UserSettings {
17
17
  mergeUserCartsOnLogin: boolean;
18
18
  autoMessagingAfterUserCreation: boolean;
19
+ guestUserMaxAgeInDays: number;
19
20
  earliestValidTokenDate: (type: typeof UserAccountAction.VERIFY_EMAIL | typeof UserAccountAction.RESET_PASSWORD) => Date;
20
21
  validateEmail: (email: string) => Promise<boolean>;
21
22
  validateUsername: (username: string) => Promise<boolean>;
@@ -8,6 +8,7 @@ export const UserAccountAction = {
8
8
  };
9
9
  const defaultAutoMessagingAfterUserCreation = true;
10
10
  const defaultMergeUserCartsOnLogin = true;
11
+ const defaultGuestUserMaxAgeInDays = Number(process.env.UNCHAINED_GUEST_USER_EXPIRY_DAYS) || 30;
11
12
  const defaultEarliestValidTokenDate = () => {
12
13
  return new Date(new Date().getTime() - 1000 * 60 * 60);
13
14
  };
@@ -25,12 +26,13 @@ const defaultValidatePassword = async (password) => {
25
26
  export const userSettings = {
26
27
  autoMessagingAfterUserCreation: defaultAutoMessagingAfterUserCreation,
27
28
  mergeUserCartsOnLogin: defaultMergeUserCartsOnLogin,
29
+ guestUserMaxAgeInDays: defaultGuestUserMaxAgeInDays,
28
30
  earliestValidTokenDate: defaultEarliestValidTokenDate,
29
31
  validateNewUser: defaultValidateNewUser,
30
32
  validateEmail: () => Promise.resolve(true),
31
33
  validateUsername: () => Promise.resolve(true),
32
34
  validatePassword: () => Promise.resolve(true),
33
- configureSettings: ({ mergeUserCartsOnLogin, autoMessagingAfterUserCreation, earliestValidTokenDate, validateEmail, validateUsername, validateNewUser, validatePassword, }, db) => {
35
+ configureSettings: ({ mergeUserCartsOnLogin, autoMessagingAfterUserCreation, guestUserMaxAgeInDays, earliestValidTokenDate, validateEmail, validateUsername, validateNewUser, validatePassword, }, db) => {
34
36
  const defaultValidateEmail = async (rawEmail) => {
35
37
  if (!rawEmail?.includes?.('@'))
36
38
  return false;
@@ -54,6 +56,7 @@ export const userSettings = {
54
56
  userSettings.mergeUserCartsOnLogin = mergeUserCartsOnLogin ?? defaultMergeUserCartsOnLogin;
55
57
  userSettings.autoMessagingAfterUserCreation =
56
58
  autoMessagingAfterUserCreation ?? defaultAutoMessagingAfterUserCreation;
59
+ userSettings.guestUserMaxAgeInDays = guestUserMaxAgeInDays ?? defaultGuestUserMaxAgeInDays;
57
60
  userSettings.earliestValidTokenDate = earliestValidTokenDate || defaultEarliestValidTokenDate;
58
61
  userSettings.validateEmail = validateEmail || defaultValidateEmail;
59
62
  userSettings.validateUsername = validateUsername || defaultValidateUsername;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@unchainedshop/core-users",
3
3
  "description": "User management module for the Unchained Engine with authentication support",
4
- "version": "5.0.0-alpha.1",
4
+ "version": "5.0.0-alpha.3",
5
5
  "main": "lib/users-index.js",
6
6
  "types": "lib/users-index.d.ts",
7
7
  "type": "module",
@@ -39,7 +39,6 @@
39
39
  "@unchainedshop/events": "^5.0.0-alpha.1",
40
40
  "@unchainedshop/logger": "^5.0.0-alpha.1",
41
41
  "@unchainedshop/mongodb": "^5.0.0-alpha.1",
42
- "@unchainedshop/roles": "^5.0.0-alpha.1",
43
42
  "@unchainedshop/utils": "^5.0.0-alpha.1",
44
43
  "bcryptjs": "^3.0.2"
45
44
  },
@@ -58,7 +57,7 @@
58
57
  "devDependencies": {
59
58
  "@noble/curves": "^2.0.0",
60
59
  "@noble/hashes": "^2.0.0",
61
- "@types/node": "^25.0.0",
60
+ "@types/node": "^26.2.0",
62
61
  "typescript": "^5.8.3"
63
62
  }
64
63
  }