diff --git a/migrations/1778000000000-private-address-book-attribution-to-user-id.ts b/migrations/1778000000000-private-address-book-attribution-to-user-id.ts new file mode 100644 index 0000000000..a0470340c9 --- /dev/null +++ b/migrations/1778000000000-private-address-book-attribution-to-user-id.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: FSL-1.1-MIT +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PrivateAddressBookAttributionToUserId1778000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + // Pre-prod: drop wallet-string actor columns; OIDC users have no wallet. + // Identity is now derived from existing user FKs. + + await queryRunner.query(` + ALTER TABLE user_address_book_items + DROP COLUMN created_by; + `); + + await queryRunner.query(` + ALTER TABLE address_book_requests + DROP COLUMN requested_by_wallet, + DROP COLUMN reviewed_by; + `); + + await queryRunner.query(` + ALTER TABLE address_book_requests + ADD COLUMN reviewed_by integer; + `); + + await queryRunner.query(` + ALTER TABLE address_book_requests + ADD CONSTRAINT "FK_ABR_reviewed_by" + FOREIGN KEY (reviewed_by) REFERENCES users(id) ON DELETE SET NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE address_book_requests + DROP CONSTRAINT "FK_ABR_reviewed_by"; + `); + + await queryRunner.query(` + ALTER TABLE address_book_requests + DROP COLUMN reviewed_by; + `); + + await queryRunner.query(` + ALTER TABLE address_book_requests + ADD COLUMN requested_by_wallet varchar(42), + ADD COLUMN reviewed_by varchar(42); + `); + + await queryRunner.query(` + ALTER TABLE user_address_book_items + ADD COLUMN created_by varchar(42); + `); + } +} diff --git a/src/modules/spaces/datasources/entities/address-book-request.entity.db.ts b/src/modules/spaces/datasources/entities/address-book-request.entity.db.ts index de2017cf6a..c4c2e85611 100644 --- a/src/modules/spaces/datasources/entities/address-book-request.entity.db.ts +++ b/src/modules/spaces/datasources/entities/address-book-request.entity.db.ts @@ -11,7 +11,6 @@ import { } from 'typeorm'; import type { Address } from 'viem'; import { databaseAddressTransformer } from '@/domain/common/transformers/databaseAddress.transformer'; -import { nullableDatabaseAddressTransformer } from '@/domain/common/transformers/nullableDatabaseAddress.transformer'; import { databaseEnumTransformer } from '@/domain/common/utils/enum'; import { Space } from '@/modules/spaces/datasources/entities/space.entity.db'; import { ADDRESS_BOOK_NAME_MAX_LENGTH } from '@/modules/spaces/domain/address-books/entities/address-book-item.entity'; @@ -67,15 +66,6 @@ export class AddressBookRequest implements DomainAddressBookRequest { }) public readonly chainIds!: Array; - @Column({ - name: 'requested_by_wallet', - type: 'varchar', - length: 42, - nullable: false, - transformer: databaseAddressTransformer, - }) - requestedByWallet!: Address; - @Column({ name: 'address', type: 'varchar', @@ -99,12 +89,10 @@ export class AddressBookRequest implements DomainAddressBookRequest { @Column({ name: 'reviewed_by', - type: 'varchar', - length: 42, + type: 'integer', nullable: true, - transformer: nullableDatabaseAddressTransformer, }) - reviewedBy!: Address | null; + reviewedBy!: number | null; @Column({ name: 'created_at', diff --git a/src/modules/spaces/datasources/entities/user-address-book-item.entity.db.ts b/src/modules/spaces/datasources/entities/user-address-book-item.entity.db.ts index a6b82045cb..389bdd6fc9 100644 --- a/src/modules/spaces/datasources/entities/user-address-book-item.entity.db.ts +++ b/src/modules/spaces/datasources/entities/user-address-book-item.entity.db.ts @@ -53,15 +53,6 @@ export class UserAddressBookItem implements DomainUserAddressBookItem { }) public readonly creator!: User; - @Column({ - name: 'created_by', - type: 'varchar', - length: 42, - nullable: false, - transformer: databaseAddressTransformer, - }) - createdBy!: Address; - @Column({ name: 'chain_ids', type: 'varchar', diff --git a/src/modules/spaces/domain/address-books/address-book-requests.repository.interface.ts b/src/modules/spaces/domain/address-books/address-book-requests.repository.interface.ts index a51611955b..ee24615b8e 100644 --- a/src/modules/spaces/domain/address-books/address-book-requests.repository.interface.ts +++ b/src/modules/spaces/domain/address-books/address-book-requests.repository.interface.ts @@ -1,6 +1,5 @@ // SPDX-License-Identifier: FSL-1.1-MIT -import type { Address } from 'viem'; import type { AddressBookItem } from '@/modules/spaces/domain/address-books/entities/address-book-item.entity'; import type { AddressBookRequest, @@ -33,7 +32,6 @@ export interface IAddressBookRequestsRepository { create(args: { spaceId: Space['id']; requestedById: User['id']; - requestedByWallet: Address; item: AddressBookItem; }): Promise; @@ -41,7 +39,7 @@ export interface IAddressBookRequestsRepository { id: AddressBookRequest['id']; spaceId: Space['id']; toStatus: 'APPROVED' | 'REJECTED'; - reviewedBy: Address; + reviewedBy: User['id']; }): Promise; revertToPending(args: { diff --git a/src/modules/spaces/domain/address-books/address-book-requests.repository.ts b/src/modules/spaces/domain/address-books/address-book-requests.repository.ts index ef5621f4dd..5b83604574 100644 --- a/src/modules/spaces/domain/address-books/address-book-requests.repository.ts +++ b/src/modules/spaces/domain/address-books/address-book-requests.repository.ts @@ -2,7 +2,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import type { FindOptionsWhere, InsertResult } from 'typeorm'; -import type { Address } from 'viem'; import { PostgresDatabaseService } from '@/datasources/db/v2/postgres-database.service'; import { isUniqueConstraintError } from '@/datasources/errors/helpers/is-unique-constraint-error.helper'; import { UniqueConstraintError } from '@/datasources/errors/unique-constraint-error'; @@ -81,7 +80,6 @@ export class AddressBookRequestsRepository public async create(args: { spaceId: Space['id']; requestedById: User['id']; - requestedByWallet: Address; item: AddressBookItem; }): Promise { const repository = await this.db.getRepository(DbAddressBookRequest); @@ -90,7 +88,6 @@ export class AddressBookRequestsRepository result = await repository.insert({ space: { id: args.spaceId }, requestedBy: { id: args.requestedById }, - requestedByWallet: args.requestedByWallet, address: args.item.address, name: args.item.name, chainIds: args.item.chainIds, @@ -112,7 +109,7 @@ export class AddressBookRequestsRepository id: AddressBookRequest['id']; spaceId: Space['id']; toStatus: 'APPROVED' | 'REJECTED'; - reviewedBy: Address; + reviewedBy: User['id']; }): Promise { const repository = await this.db.getRepository(DbAddressBookRequest); const result = await repository.update( diff --git a/src/modules/spaces/domain/address-books/entities/address-book-request.entity.ts b/src/modules/spaces/domain/address-books/entities/address-book-request.entity.ts index 127caa249e..8f98bf2b71 100644 --- a/src/modules/spaces/domain/address-books/entities/address-book-request.entity.ts +++ b/src/modules/spaces/domain/address-books/entities/address-book-request.entity.ts @@ -23,22 +23,20 @@ export const AddressBookRequestSchema: z.ZodType< z.infer & { space: Space; requestedBy: User; - requestedByWallet: Address; chainIds: Array; address: Address; name: string; status: keyof typeof AddressBookRequestStatus; - reviewedBy: Address | null; + reviewedBy: number | null; } > = RowSchema.extend({ space: z.lazy(() => SpaceSchema), requestedBy: z.lazy(() => UserSchema), - requestedByWallet: AddressSchema as z.ZodType
, chainIds: z.array(z.string()), address: AddressSchema as z.ZodType
, name: makeNameSchema({ maxLength: ADDRESS_BOOK_NAME_MAX_LENGTH }), status: z.enum(getStringEnumKeys(AddressBookRequestStatus)), - reviewedBy: (AddressSchema as z.ZodType
).nullable(), + reviewedBy: z.number().int().nullable(), }); export type AddressBookRequest = z.infer; diff --git a/src/modules/spaces/domain/address-books/entities/user-address-book-item.entity.ts b/src/modules/spaces/domain/address-books/entities/user-address-book-item.entity.ts index bb161d066f..7888d469e7 100644 --- a/src/modules/spaces/domain/address-books/entities/user-address-book-item.entity.ts +++ b/src/modules/spaces/domain/address-books/entities/user-address-book-item.entity.ts @@ -16,7 +16,6 @@ export const UserAddressBookItemSchema: z.ZodType< z.infer & { space: Space; creator: User; - createdBy: Address; chainIds: Array; address: Address; name: string; @@ -24,7 +23,6 @@ export const UserAddressBookItemSchema: z.ZodType< > = RowSchema.extend({ space: z.lazy(() => SpaceSchema), creator: z.lazy(() => UserSchema), - createdBy: AddressSchema as z.ZodType
, chainIds: z.array(z.string()), address: AddressSchema as z.ZodType
, name: makeNameSchema({ maxLength: ADDRESS_BOOK_NAME_MAX_LENGTH }), diff --git a/src/modules/spaces/domain/address-books/user-address-book-items.repository.interface.ts b/src/modules/spaces/domain/address-books/user-address-book-items.repository.interface.ts index f840da12df..b58d15c610 100644 --- a/src/modules/spaces/domain/address-books/user-address-book-items.repository.interface.ts +++ b/src/modules/spaces/domain/address-books/user-address-book-items.repository.interface.ts @@ -1,6 +1,5 @@ // SPDX-License-Identifier: FSL-1.1-MIT -import type { Address } from 'viem'; import type { AddressBookItem } from '@/modules/spaces/domain/address-books/entities/address-book-item.entity'; import type { UserAddressBookItem } from '@/modules/spaces/domain/address-books/entities/user-address-book-item.entity'; import type { Space } from '@/modules/spaces/domain/entities/space.entity'; @@ -25,7 +24,6 @@ export interface IUserAddressBookItemsRepository { upsertMany(args: { spaceId: Space['id']; creatorId: User['id']; - signerAddress: Address; items: Array; }): Promise>; diff --git a/src/modules/spaces/domain/address-books/user-address-book-items.repository.ts b/src/modules/spaces/domain/address-books/user-address-book-items.repository.ts index 6f8655da09..839adc1f74 100644 --- a/src/modules/spaces/domain/address-books/user-address-book-items.repository.ts +++ b/src/modules/spaces/domain/address-books/user-address-book-items.repository.ts @@ -2,7 +2,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { type EntityManager, In } from 'typeorm'; -import { type Address, isAddressEqual } from 'viem'; +import { isAddressEqual } from 'viem'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { PostgresDatabaseService } from '@/datasources/db/v2/postgres-database.service'; import { UserAddressBookItem as DbUserAddressBookItem } from '@/modules/spaces/datasources/entities/user-address-book-item.entity.db'; @@ -55,7 +55,6 @@ export class UserAddressBookItemsRepository public async upsertMany(args: { spaceId: Space['id']; creatorId: User['id']; - signerAddress: Address; items: Array; }): Promise> { const repository = await this.db.getRepository(DbUserAddressBookItem); @@ -84,7 +83,6 @@ export class UserAddressBookItemsRepository newItems.map((item) => ({ space: { id: args.spaceId }, creator: { id: args.creatorId }, - createdBy: args.signerAddress, address: item.address, name: item.name, chainIds: item.chainIds, diff --git a/src/modules/spaces/routes/address-book-requests.controller.e2e-spec.ts b/src/modules/spaces/routes/address-book-requests.controller.e2e-spec.ts index 31a167fde2..f266e46121 100644 --- a/src/modules/spaces/routes/address-book-requests.controller.e2e-spec.ts +++ b/src/modules/spaces/routes/address-book-requests.controller.e2e-spec.ts @@ -10,13 +10,18 @@ import { createTestModule } from '@/__tests__/testing-module'; import configuration from '@/config/entities/__tests__/configuration'; import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; import { nameBuilder } from '@/domain/common/entities/name.builder'; -import { siweAuthPayloadDtoBuilder } from '@/modules/auth/domain/entities/__tests__/auth-payload-dto.entity.builder'; +import { + oidcAuthPayloadDtoBuilder, + siweAuthPayloadDtoBuilder, +} from '@/modules/auth/domain/entities/__tests__/auth-payload-dto.entity.builder'; import { NotificationsRepositoryV2Module } from '@/modules/notifications/domain/v2/notifications.repository.module'; import { TestNotificationsRepositoryV2Module } from '@/modules/notifications/domain/v2/test.notification.repository.module'; +import { IUsersRepository } from '@/modules/users/domain/users.repository.interface'; describe('AddressBookRequestsController', () => { let app: INestApplication; let jwtService: IJwtService; + let usersRepository: IUsersRepository; const defaultConfiguration = configuration(); @@ -45,6 +50,7 @@ describe('AddressBookRequestsController', () => { }); jwtService = moduleFixture.get(IJwtService); + usersRepository = moduleFixture.get(IUsersRepository); app = await new TestAppProvider().provide(moduleFixture); await app.init(); return app; @@ -83,6 +89,9 @@ describe('AddressBookRequestsController', () => { address: mockAddress, status: 'PENDING', requestedBy: expect.any(String), + requestedByUserId: expect.any(Number), + reviewedBy: null, + reviewedByUserId: null, createdAt: expect.any(String), updatedAt: expect.any(String), }), @@ -114,6 +123,35 @@ describe('AddressBookRequestsController', () => { .send({ address: fakeAddress }) .expect(403); }); + + it('should create a request as an OIDC member (admin of own space)', async () => { + const { spaceId, accessToken, userId } = await createSpaceAsOidcAdmin(); + const { mockName, mockAddress, mockChainIds } = + await createPrivateContact({ + spaceId, + accessToken, + }); + + const response = await request(app.getHttpServer()) + .post(`/v1/spaces/${spaceId}/address-book/requests`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address: mockAddress }) + .expect(201); + + expect(response.body).toEqual({ + id: expect.any(Number), + name: mockName, + address: mockAddress, + chainIds: mockChainIds, + requestedBy: expect.any(String), + requestedByUserId: userId, + reviewedBy: null, + reviewedByUserId: null, + status: 'PENDING', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); }); describe('GET /spaces/:spaceId/address-book/requests', () => { @@ -275,6 +313,51 @@ describe('AddressBookRequestsController', () => { .set('Cookie', [`access_token=${memberAccessToken}`]) .expect(403); }); + + it('should approve a request as an OIDC admin', async () => { + const { + spaceId, + accessToken: adminToken, + userId: adminUserId, + } = await createSpaceAsOidcAdmin(); + const { memberAccessToken } = await inviteMember({ + spaceId, + adminAccessToken: adminToken, + }); + const { mockAddress } = await createPrivateContact({ + spaceId, + accessToken: memberAccessToken, + }); + const createResponse = await request(app.getHttpServer()) + .post(`/v1/spaces/${spaceId}/address-book/requests`) + .set('Cookie', [`access_token=${memberAccessToken}`]) + .send({ address: mockAddress }) + .expect(201); + const requestId = createResponse.body.id; + + await request(app.getHttpServer()) + .put(`/v1/spaces/${spaceId}/address-book/requests/${requestId}/approve`) + .set('Cookie', [`access_token=${adminToken}`]) + .expect(200); + + // Approved requests are no longer in the PENDING list, but the shared + // space address book should now contain the entry. + await request(app.getHttpServer()) + .get(`/v1/spaces/${spaceId}/address-book`) + .set('Cookie', [`access_token=${adminToken}`]) + .expect(200) + .expect(({ body }) => { + expect(body.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + address: mockAddress, + createdByUserId: expect.any(Number), + lastUpdatedByUserId: adminUserId, + }), + ]), + ); + }); + }); }); describe('PUT /spaces/:spaceId/address-book/requests/:id/reject', () => { @@ -352,6 +435,37 @@ describe('AddressBookRequestsController', () => { .set('Cookie', [`access_token=${accessToken}`]) .expect(400); }); + + it('should reject a request as an OIDC admin', async () => { + const { spaceId, accessToken: adminToken } = + await createSpaceAsOidcAdmin(); + const { memberAccessToken } = await inviteMember({ + spaceId, + adminAccessToken: adminToken, + }); + const { mockAddress } = await createPrivateContact({ + spaceId, + accessToken: memberAccessToken, + }); + const createResponse = await request(app.getHttpServer()) + .post(`/v1/spaces/${spaceId}/address-book/requests`) + .set('Cookie', [`access_token=${memberAccessToken}`]) + .send({ address: mockAddress }) + .expect(201); + const requestId = createResponse.body.id; + + await request(app.getHttpServer()) + .put(`/v1/spaces/${spaceId}/address-book/requests/${requestId}/reject`) + .set('Cookie', [`access_token=${adminToken}`]) + .expect(200); + + // Rejected requests are not in the PENDING list; member sees an empty list. + await request(app.getHttpServer()) + .get(`/v1/spaces/${spaceId}/address-book/requests`) + .set('Cookie', [`access_token=${memberAccessToken}`]) + .expect(200) + .expect({ spaceId: spaceId.toString(), data: [] }); + }); }); // Utility functions @@ -376,6 +490,34 @@ describe('AddressBookRequestsController', () => { return { spaceId, accessToken }; }; + const createSpaceAsOidcAdmin = async (): Promise<{ + spaceId: string; + accessToken: string; + userId: number; + email: string; + }> => { + const email = faker.internet.email().toLowerCase(); + const userId = await usersRepository.findOrCreateByExtUserIdWithEmail( + faker.string.uuid(), + { address: email, verified: true }, + ); + const authPayloadDto = oidcAuthPayloadDtoBuilder() + .with('sub', userId.toString()) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + const createSpaceResponse = await request(app.getHttpServer()) + .post('/v1/spaces') + .set('Cookie', [`access_token=${accessToken}`]) + .send({ name: nameBuilder() }) + .expect(201); + return { + spaceId: createSpaceResponse.body.id, + accessToken, + userId, + email, + }; + }; + const inviteMember = async (args: { spaceId: string; adminAccessToken: string; diff --git a/src/modules/spaces/routes/address-book-requests.controller.ts b/src/modules/spaces/routes/address-book-requests.controller.ts index e4c03864a3..ad19298642 100644 --- a/src/modules/spaces/routes/address-book-requests.controller.ts +++ b/src/modules/spaces/routes/address-book-requests.controller.ts @@ -96,7 +96,7 @@ export class AddressBookRequestsController { description: 'Private contact not found', }) @ApiForbiddenResponse({ - description: 'User is not a member or wallet authentication required', + description: 'User is not a member of this space', }) @Post('/:spaceId/address-book/requests') @UseGuards(AuthGuard) @@ -132,7 +132,7 @@ export class AddressBookRequestsController { description: 'Only pending requests can be approved', }) @ApiForbiddenResponse({ - description: 'User is not an admin or wallet authentication required', + description: 'User is not an admin of this space', }) @Put('/:spaceId/address-book/requests/:requestId/approve') @UseGuards(AuthGuard) @@ -168,7 +168,7 @@ export class AddressBookRequestsController { description: 'Only pending requests can be rejected', }) @ApiForbiddenResponse({ - description: 'User is not an admin or wallet authentication required', + description: 'User is not an admin of this space', }) @Put('/:spaceId/address-book/requests/:requestId/reject') @UseGuards(AuthGuard) diff --git a/src/modules/spaces/routes/address-book-requests.service.ts b/src/modules/spaces/routes/address-book-requests.service.ts index 7f7dff3dd1..ca307bd05a 100644 --- a/src/modules/spaces/routes/address-book-requests.service.ts +++ b/src/modules/spaces/routes/address-book-requests.service.ts @@ -2,7 +2,6 @@ import { BadRequestException, - ForbiddenException, Inject, Injectable, NotFoundException, @@ -26,6 +25,7 @@ import { isAdmin, } from '@/modules/spaces/routes/utils/space-assert.utils'; import { IMembersRepository } from '@/modules/users/domain/members.repository.interface'; +import { UserIdentityResolverService } from '@/modules/users/domain/user-identity-resolver.service'; @Injectable() export class AddressBookRequestsService { @@ -40,6 +40,8 @@ export class AddressBookRequestsService { private readonly membersRepository: IMembersRepository, @Inject(ISpacesRepository) private readonly spacesRepository: ISpacesRepository, + @Inject(UserIdentityResolverService) + private readonly identityResolver: UserIdentityResolverService, ) {} public async findPending( @@ -70,16 +72,9 @@ export class AddressBookRequestsService { spaceId: Space['id'], address: Address, ): Promise { - if (!authPayload.isSiwe()) { - throw new ForbiddenException( - 'Address book writes require wallet authentication', - ); - } - const userId = getAuthenticatedUserIdOrFail(authPayload); await assertMember(this.membersRepository, spaceId, userId); - // Look up the private contact const privateContact = await this.privateRepository.findOneBySpaceCreatorAndAddress({ spaceId, @@ -96,7 +91,6 @@ export class AddressBookRequestsService { const request = await this.requestsRepository.create({ spaceId, requestedById: userId, - requestedByWallet: authPayload.signer_address, item: { name: privateContact.name, address: privateContact.address, @@ -104,7 +98,8 @@ export class AddressBookRequestsService { }, }); - return this.mapRequestItem(request); + const { data } = await this.mapRequests(spaceId, [request]); + return data[0]; } public async approve( @@ -112,12 +107,6 @@ export class AddressBookRequestsService { spaceId: Space['id'], requestId: number, ): Promise { - if (!authPayload.isSiwe()) { - throw new ForbiddenException( - 'Address book writes require wallet authentication', - ); - } - const userId = getAuthenticatedUserIdOrFail(authPayload); await assertAdmin(this.spacesRepository, spaceId, userId); @@ -126,12 +115,11 @@ export class AddressBookRequestsService { spaceId, }); - // Atomically claim the request so concurrent approvals cannot both proceed. const claimed = await this.requestsRepository.transitionFromPending({ id: requestId, spaceId, toStatus: 'APPROVED', - reviewedBy: authPayload.signer_address, + reviewedBy: userId, }); if (!claimed) { throw new BadRequestException('Only pending requests can be approved.'); @@ -151,7 +139,6 @@ export class AddressBookRequestsService { createdByOverride: request.requestedBy.id, }); } catch (err) { - // Compensate so the request can be retried if the upsert fails. await this.requestsRepository.revertToPending({ id: requestId, spaceId, @@ -165,12 +152,6 @@ export class AddressBookRequestsService { spaceId: Space['id'], requestId: number, ): Promise { - if (!authPayload.isSiwe()) { - throw new ForbiddenException( - 'Address book writes require wallet authentication', - ); - } - const userId = getAuthenticatedUserIdOrFail(authPayload); await assertAdmin(this.spacesRepository, spaceId, userId); @@ -178,32 +159,50 @@ export class AddressBookRequestsService { id: requestId, spaceId, toStatus: 'REJECTED', - reviewedBy: authPayload.signer_address, + reviewedBy: userId, }); if (!rejected) { throw new BadRequestException('Only pending requests can be rejected.'); } } - private mapRequests( + private async mapRequests( spaceId: Space['id'], requests: Array, - ): AddressBookRequestsDto { + ): Promise { + const identityMap = await this.identityResolver.resolveMany( + requests.flatMap((r) => + r.reviewedBy !== null + ? [r.requestedBy.id, r.reviewedBy] + : [r.requestedBy.id], + ), + ); + return { spaceId: spaceId.toString(), - data: requests.map((request) => this.mapRequestItem(request)), + data: requests.map((request) => this.toDto(request, identityMap)), }; } - private mapRequestItem( + private toDto( request: AddressBookRequest, + identityMap: Map, ): AddressBookRequestItemDto { return { id: request.id, name: request.name, address: request.address, chainIds: request.chainIds, - requestedBy: request.requestedByWallet, + requestedBy: + identityMap.get(request.requestedBy.id) ?? + UserIdentityResolverService.DELETED_USER_LABEL, + requestedByUserId: request.requestedBy.id, + reviewedBy: + request.reviewedBy === null + ? null + : (identityMap.get(request.reviewedBy) ?? + UserIdentityResolverService.DELETED_USER_LABEL), + reviewedByUserId: request.reviewedBy, status: request.status, createdAt: request.createdAt, updatedAt: request.updatedAt, diff --git a/src/modules/spaces/routes/address-books.service.spec.ts b/src/modules/spaces/routes/address-books.service.spec.ts index cc33e63284..8dfd882389 100644 --- a/src/modules/spaces/routes/address-books.service.spec.ts +++ b/src/modules/spaces/routes/address-books.service.spec.ts @@ -12,6 +12,7 @@ import type { IAddressBookItemsRepository } from '@/modules/spaces/domain/addres import { addressBookItemBuilder } from '@/modules/spaces/domain/address-books/entities/__tests__/address-book-item.db.builder'; import { AddressBooksService } from '@/modules/spaces/routes/address-books.service'; import { userBuilder } from '@/modules/users/datasources/entities/__tests__/users.entity.db.builder'; +import { UserIdentityResolverService } from '@/modules/users/domain/user-identity-resolver.service'; import type { IUsersRepository } from '@/modules/users/domain/users.repository.interface'; import { walletBuilder } from '@/modules/wallets/datasources/entities/__tests__/wallets.entity.db.builder'; import type { IWalletsRepository } from '@/modules/wallets/domain/wallets.repository.interface'; @@ -44,8 +45,10 @@ describe('AddressBooksService', () => { walletsRepositoryMock.find.mockResolvedValue([]); service = new AddressBooksService( repositoryMock, - usersRepositoryMock, - walletsRepositoryMock, + new UserIdentityResolverService( + usersRepositoryMock, + walletsRepositoryMock, + ), configurationServiceMock, ); }); diff --git a/src/modules/spaces/routes/address-books.service.ts b/src/modules/spaces/routes/address-books.service.ts index 2ea65b37bb..7ffcdc179c 100644 --- a/src/modules/spaces/routes/address-books.service.ts +++ b/src/modules/spaces/routes/address-books.service.ts @@ -1,6 +1,5 @@ // SPDX-License-Identifier: FSL-1.1-MIT import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; import { IConfigurationService } from '@/config/configuration.service.interface'; import type { AuthPayload } from '@/modules/auth/domain/entities/auth-payload.entity'; import type { Space } from '@/modules/spaces/datasources/entities/space.entity.db'; @@ -8,13 +7,10 @@ import { IAddressBookItemsRepository } from '@/modules/spaces/domain/address-boo import type { AddressBookDbItem } from '@/modules/spaces/domain/address-books/entities/address-book-item.db.entity'; import type { SpaceAddressBookDto } from '@/modules/spaces/routes/entities/space-address-book.dto.entity'; import type { UpsertAddressBookItemsDto } from '@/modules/spaces/routes/entities/upsert-address-book-items.dto.entity'; -import { IUsersRepository } from '@/modules/users/domain/users.repository.interface'; -import { IWalletsRepository } from '@/modules/wallets/domain/wallets.repository.interface'; +import { UserIdentityResolverService } from '@/modules/users/domain/user-identity-resolver.service'; @Injectable() export class AddressBooksService { - private static readonly DELETED_USER_LABEL = 'Deleted user'; - private static readonly UNKNOWN_USER_LABEL = 'Unknown user'; // TODO: Investigate and implement usage of this // biome-ignore lint/correctness/noUnusedPrivateClassMembers: <> private readonly maxItems: number; @@ -22,10 +18,8 @@ export class AddressBooksService { constructor( @Inject(IAddressBookItemsRepository) private readonly repository: IAddressBookItemsRepository, - @Inject(IUsersRepository) - private readonly usersRepository: IUsersRepository, - @Inject(IWalletsRepository) - private readonly walletsRepository: IWalletsRepository, + @Inject(UserIdentityResolverService) + private readonly identityResolver: UserIdentityResolverService, @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, ) { @@ -38,13 +32,14 @@ export class AddressBooksService { authPayload: AuthPayload, spaceId: Space['id'], ): Promise { - const addressBookItems = await this.repository.findAllBySpaceId({ + const items = await this.repository.findAllBySpaceId({ authPayload, spaceId, }); - const userIdentityMap = await this.buildUserIdentityMap(addressBookItems); - - return this.mapAddressBookItems(spaceId, addressBookItems, userIdentityMap); + const identityMap = await this.identityResolver.resolveMany( + items.flatMap((item) => [item.createdBy, item.lastUpdatedBy]), + ); + return this.mapAddressBookItems(spaceId, items, identityMap); } public async upsertMany( @@ -52,14 +47,15 @@ export class AddressBooksService { spaceId: Space['id'], addressBookItems: UpsertAddressBookItemsDto, ): Promise { - const updatedItems = await this.repository.upsertMany({ + const updated = await this.repository.upsertMany({ authPayload, spaceId, addressBookItems: addressBookItems.items, }); - const userIdentityMap = await this.buildUserIdentityMap(updatedItems); - - return this.mapAddressBookItems(spaceId, updatedItems, userIdentityMap); + const identityMap = await this.identityResolver.resolveMany( + updated.flatMap((item) => [item.createdBy, item.lastUpdatedBy]), + ); + return this.mapAddressBookItems(spaceId, updated, identityMap); } public async deleteByAddress(args: { @@ -70,67 +66,28 @@ export class AddressBooksService { await this.repository.deleteByAddress(args); } - /** - * Loads users for all unique user IDs in createdBy/lastUpdatedBy, - * and builds a map: userId → first wallet address or email. - */ - private async buildUserIdentityMap( - items: Array, - ): Promise> { - const userIds = [ - ...new Set(items.flatMap((item) => [item.createdBy, item.lastUpdatedBy])), - ]; - if (userIds.length === 0) return new Map(); - - const [users, wallets] = await Promise.all([ - this.usersRepository.find({ id: In(userIds) }), - this.walletsRepository.find({ - where: { user: { id: In(userIds) } }, - relations: { user: true }, - }), - ]); - - const walletAddressByUserId = new Map(); - for (const wallet of wallets) { - if (!walletAddressByUserId.has(wallet.user.id)) { - walletAddressByUserId.set(wallet.user.id, wallet.address); - } - } - - return new Map( - users.map((user): [number, string] => [ - user.id, - walletAddressByUserId.get(user.id) ?? - user.email ?? - AddressBooksService.UNKNOWN_USER_LABEL, - ]), - ); - } - private mapAddressBookItems( spaceId: Space['id'], items: Array, - userIdentityMap: Map, + identityMap: Map, ): SpaceAddressBookDto { - const data = items.map((item) => ({ - name: item.name, - address: item.address, - chainIds: item.chainIds, - createdBy: - userIdentityMap.get(item.createdBy) ?? - AddressBooksService.DELETED_USER_LABEL, - createdByUserId: item.createdBy, - lastUpdatedBy: - userIdentityMap.get(item.lastUpdatedBy) ?? - AddressBooksService.DELETED_USER_LABEL, - lastUpdatedByUserId: item.lastUpdatedBy, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - })); - return { spaceId: spaceId.toString(), - data, + data: items.map((item) => ({ + name: item.name, + address: item.address, + chainIds: item.chainIds, + createdBy: + identityMap.get(item.createdBy) ?? + UserIdentityResolverService.DELETED_USER_LABEL, + createdByUserId: item.createdBy, + lastUpdatedBy: + identityMap.get(item.lastUpdatedBy) ?? + UserIdentityResolverService.DELETED_USER_LABEL, + lastUpdatedByUserId: item.lastUpdatedBy, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + })), }; } } diff --git a/src/modules/spaces/routes/entities/address-book-request.dto.entity.ts b/src/modules/spaces/routes/entities/address-book-request.dto.entity.ts index 05d2487c77..64ebe13ccf 100644 --- a/src/modules/spaces/routes/entities/address-book-request.dto.entity.ts +++ b/src/modules/spaces/routes/entities/address-book-request.dto.entity.ts @@ -19,9 +19,31 @@ export class AddressBookRequestItemDto { @ApiProperty({ type: String, isArray: true }) public chainIds!: Array; - @ApiProperty({ type: String }) + @ApiProperty({ + type: String, + description: + 'Email or wallet address of the requester, "Unknown user" if the user has no display identity, or "Deleted user"', + }) public requestedBy!: string; + @ApiProperty({ type: Number, description: 'User ID of the requester' }) + public requestedByUserId!: number; + + @ApiProperty({ + type: String, + nullable: true, + description: + 'Email or wallet address of the reviewing admin, "Unknown user", "Deleted user", or null when still PENDING', + }) + public reviewedBy!: string | null; + + @ApiProperty({ + type: Number, + nullable: true, + description: 'User ID of the reviewing admin, null when still PENDING', + }) + public reviewedByUserId!: number | null; + @ApiProperty({ enum: getStringEnumKeys(AddressBookRequestStatus) }) public status!: keyof typeof AddressBookRequestStatus; diff --git a/src/modules/spaces/routes/entities/space-address-book.dto.entity.ts b/src/modules/spaces/routes/entities/space-address-book.dto.entity.ts index 186b355b50..96f7529ffa 100644 --- a/src/modules/spaces/routes/entities/space-address-book.dto.entity.ts +++ b/src/modules/spaces/routes/entities/space-address-book.dto.entity.ts @@ -65,8 +65,15 @@ export class UserAddressBookItemDto { @ApiProperty({ type: String, isArray: true }) public chainIds!: UserAddressBookItem['chainIds']; - @ApiProperty({ type: String }) - public createdBy!: UserAddressBookItem['createdBy']; + @ApiProperty({ + type: String, + description: + 'Email or wallet address of the creator, "Unknown user" if the user has no display identity, or "Deleted user"', + }) + public createdBy!: string; + + @ApiProperty({ type: Number, description: 'User ID of the creator' }) + public createdByUserId!: number; @ApiProperty() public createdAt!: UserAddressBookItem['createdAt']; diff --git a/src/modules/spaces/routes/user-address-book.controller.e2e-spec.ts b/src/modules/spaces/routes/user-address-book.controller.e2e-spec.ts index 0ad7ef9120..9a52743c11 100644 --- a/src/modules/spaces/routes/user-address-book.controller.e2e-spec.ts +++ b/src/modules/spaces/routes/user-address-book.controller.e2e-spec.ts @@ -10,13 +10,18 @@ import { createTestModule } from '@/__tests__/testing-module'; import configuration from '@/config/entities/__tests__/configuration'; import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; import { nameBuilder } from '@/domain/common/entities/name.builder'; -import { siweAuthPayloadDtoBuilder } from '@/modules/auth/domain/entities/__tests__/auth-payload-dto.entity.builder'; +import { + oidcAuthPayloadDtoBuilder, + siweAuthPayloadDtoBuilder, +} from '@/modules/auth/domain/entities/__tests__/auth-payload-dto.entity.builder'; import { NotificationsRepositoryV2Module } from '@/modules/notifications/domain/v2/notifications.repository.module'; import { TestNotificationsRepositoryV2Module } from '@/modules/notifications/domain/v2/test.notification.repository.module'; +import { IUsersRepository } from '@/modules/users/domain/users.repository.interface'; describe('UserAddressBookController', () => { let app: INestApplication; let jwtService: IJwtService; + let usersRepository: IUsersRepository; const defaultConfiguration = configuration(); @@ -45,6 +50,7 @@ describe('UserAddressBookController', () => { }); jwtService = moduleFixture.get(IJwtService); + usersRepository = moduleFixture.get(IUsersRepository); app = await new TestAppProvider().provide(moduleFixture); await app.init(); return app; @@ -91,6 +97,7 @@ describe('UserAddressBookController', () => { address: mockAddress, name: mockName, createdBy: expect.any(String), + createdByUserId: expect.any(Number), createdAt: expect.any(String), updatedAt: expect.any(String), }, @@ -144,6 +151,7 @@ describe('UserAddressBookController', () => { address: mockAddress, name: mockName, createdBy: expect.any(String), + createdByUserId: expect.any(Number), createdAt: expect.any(String), updatedAt: expect.any(String), }, @@ -159,6 +167,43 @@ describe('UserAddressBookController', () => { .get(`/v1/spaces/${spaceId}/address-book/private`) .expect(403); }); + + it('should return private address book entries for an OIDC user', async () => { + const { spaceId, accessToken, userId, email } = + await createSpaceAsOidcAdmin(); + + // Seed an entry. + const mockAddress = getAddress(faker.finance.ethereumAddress()); + const mockName = nameBuilder(); + await request(app.getHttpServer()) + .put(`/v1/spaces/${spaceId}/address-book/private`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ + items: [{ name: mockName, address: mockAddress, chainIds: ['1'] }], + }) + .expect(200); + + await request(app.getHttpServer()) + .get(`/v1/spaces/${spaceId}/address-book/private`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + spaceId: spaceId.toString(), + data: [ + expect.objectContaining({ + name: mockName, + address: mockAddress, + chainIds: ['1'], + createdBy: email, + createdByUserId: userId, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + ], + }); + }); + }); }); describe('PUT /spaces/:spaceId/address-book/private', () => { @@ -209,6 +254,38 @@ describe('UserAddressBookController', () => { .expect(200); }); + it('should upsert private address book entries for an OIDC user', async () => { + const { spaceId, accessToken, userId } = await createSpaceAsOidcAdmin(); + const mockAddress = getAddress(faker.finance.ethereumAddress()); + const mockName = nameBuilder(); + const mockChainIds = ['1', '100']; + + const response = await request(app.getHttpServer()) + .put(`/v1/spaces/${spaceId}/address-book/private`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ + items: [ + { name: mockName, address: mockAddress, chainIds: mockChainIds }, + ], + }) + .expect(200); + + expect(response.body).toEqual({ + spaceId: spaceId.toString(), + data: [ + expect.objectContaining({ + name: mockName, + address: mockAddress, + chainIds: mockChainIds, + createdBy: expect.any(String), + createdByUserId: userId, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + ], + }); + }); + it('should update an existing private contact', async () => { const { spaceId, accessToken } = await createSpace(); const { mockAddress } = await createPrivateContact({ @@ -260,6 +337,32 @@ describe('UserAddressBookController', () => { .expect(200) .expect({ spaceId: spaceId.toString(), data: [] }); }); + + it('should delete a private address book entry for an OIDC user', async () => { + const { spaceId, accessToken } = await createSpaceAsOidcAdmin(); + const mockAddress = getAddress(faker.finance.ethereumAddress()); + + await request(app.getHttpServer()) + .put(`/v1/spaces/${spaceId}/address-book/private`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ + items: [ + { name: nameBuilder(), address: mockAddress, chainIds: ['1'] }, + ], + }) + .expect(200); + + await request(app.getHttpServer()) + .delete(`/v1/spaces/${spaceId}/address-book/private/${mockAddress}`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(200); + + await request(app.getHttpServer()) + .get(`/v1/spaces/${spaceId}/address-book/private`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(200) + .expect({ spaceId: spaceId.toString(), data: [] }); + }); }); // Utility functions @@ -284,6 +387,34 @@ describe('UserAddressBookController', () => { return { spaceId, accessToken }; }; + const createSpaceAsOidcAdmin = async (): Promise<{ + spaceId: string; + accessToken: string; + userId: number; + email: string; + }> => { + const email = faker.internet.email().toLowerCase(); + const userId = await usersRepository.findOrCreateByExtUserIdWithEmail( + faker.string.uuid(), + { address: email, verified: true }, + ); + const authPayloadDto = oidcAuthPayloadDtoBuilder() + .with('sub', userId.toString()) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + const createSpaceResponse = await request(app.getHttpServer()) + .post('/v1/spaces') + .set('Cookie', [`access_token=${accessToken}`]) + .send({ name: nameBuilder() }) + .expect(201); + return { + spaceId: createSpaceResponse.body.id, + accessToken, + userId, + email, + }; + }; + const inviteMember = async (args: { spaceId: string; adminAccessToken: string; diff --git a/src/modules/spaces/routes/user-address-book.controller.ts b/src/modules/spaces/routes/user-address-book.controller.ts index 4c1652b516..3a3d2e4bee 100644 --- a/src/modules/spaces/routes/user-address-book.controller.ts +++ b/src/modules/spaces/routes/user-address-book.controller.ts @@ -94,8 +94,7 @@ export class UserAddressBookController { }) @ApiUnauthorizedResponse({ description: 'Authentication required' }) @ApiForbiddenResponse({ - description: - 'User is not a member of this space or wallet authentication required', + description: 'User is not a member of this space', }) @Put('/:spaceId/address-book/private') @UseGuards(AuthGuard) @@ -130,7 +129,7 @@ export class UserAddressBookController { }) @ApiNotFoundResponse({ description: 'Entry not found' }) @ApiForbiddenResponse({ - description: 'User is not a member or wallet authentication required', + description: 'User is not a member of this space', }) @Delete('/:spaceId/address-book/private/:address') @UseGuards(AuthGuard) diff --git a/src/modules/spaces/routes/user-address-book.service.ts b/src/modules/spaces/routes/user-address-book.service.ts index cda4ce04da..6337079ae9 100644 --- a/src/modules/spaces/routes/user-address-book.service.ts +++ b/src/modules/spaces/routes/user-address-book.service.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: FSL-1.1-MIT -import { ForbiddenException, Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { AuthPayload } from '@/modules/auth/domain/entities/auth-payload.entity'; import { getAuthenticatedUserIdOrFail } from '@/modules/auth/utils/assert-authenticated.utils'; import type { Space } from '@/modules/spaces/datasources/entities/space.entity.db'; @@ -10,6 +10,7 @@ import { UserAddressBookDto } from '@/modules/spaces/routes/entities/space-addre import type { UpsertAddressBookItemsDto } from '@/modules/spaces/routes/entities/upsert-address-book-items.dto.entity'; import { assertMember } from '@/modules/spaces/routes/utils/space-assert.utils'; import { IMembersRepository } from '@/modules/users/domain/members.repository.interface'; +import { UserIdentityResolverService } from '@/modules/users/domain/user-identity-resolver.service'; @Injectable() export class UserAddressBookService { @@ -18,6 +19,8 @@ export class UserAddressBookService { private readonly repository: IUserAddressBookItemsRepository, @Inject(IMembersRepository) private readonly membersRepository: IMembersRepository, + @Inject(UserIdentityResolverService) + private readonly identityResolver: UserIdentityResolverService, ) {} public async findAll( @@ -32,7 +35,7 @@ export class UserAddressBookService { creatorId: userId, }); - return this.mapItems(spaceId, items); + return this.mapItems(spaceId, userId, items); } public async upsertMany( @@ -40,23 +43,16 @@ export class UserAddressBookService { spaceId: Space['id'], dto: UpsertAddressBookItemsDto, ): Promise { - if (!authPayload.isSiwe()) { - throw new ForbiddenException( - 'Address book writes require wallet authentication', - ); - } - const userId = getAuthenticatedUserIdOrFail(authPayload); await assertMember(this.membersRepository, spaceId, userId); const items = await this.repository.upsertMany({ spaceId, creatorId: userId, - signerAddress: authPayload.signer_address, items: dto.items, }); - return this.mapItems(spaceId, items); + return this.mapItems(spaceId, userId, items); } public async deleteByAddress(args: { @@ -64,12 +60,6 @@ export class UserAddressBookService { spaceId: Space['id']; address: UserAddressBookItem['address']; }): Promise { - if (!args.authPayload.isSiwe()) { - throw new ForbiddenException( - 'Address book writes require wallet authentication', - ); - } - const userId = getAuthenticatedUserIdOrFail(args.authPayload); await assertMember(this.membersRepository, args.spaceId, userId); @@ -80,22 +70,26 @@ export class UserAddressBookService { }); } - private mapItems( + private async mapItems( spaceId: Space['id'], + userId: number, items: Array, - ): UserAddressBookDto { - const data = items.map((item) => ({ - name: item.name, - address: item.address, - chainIds: item.chainIds, - createdBy: item.createdBy, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - })); + ): Promise { + const identityMap = await this.identityResolver.resolveMany([userId]); + const createdBy = + identityMap.get(userId) ?? UserIdentityResolverService.DELETED_USER_LABEL; return { spaceId: spaceId.toString(), - data, + data: items.map((item) => ({ + name: item.name, + address: item.address, + chainIds: item.chainIds, + createdBy, + createdByUserId: userId, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + })), }; } } diff --git a/src/modules/spaces/spaces.module.ts b/src/modules/spaces/spaces.module.ts index 2efa6fa2d9..e6828a3b00 100644 --- a/src/modules/spaces/spaces.module.ts +++ b/src/modules/spaces/spaces.module.ts @@ -32,9 +32,8 @@ import { SpacesService } from '@/modules/spaces/routes/spaces.service'; import { UserAddressBookController } from '@/modules/spaces/routes/user-address-book.controller'; import { UserAddressBookService } from '@/modules/spaces/routes/user-address-book.service'; import { Member } from '@/modules/users/datasources/entities/member.entity.db'; +import { UserIdentityResolverModule } from '@/modules/users/domain/user-identity-resolver.module'; import { UsersModule } from '@/modules/users/users.module'; -import { WalletsRepository } from '@/modules/wallets/domain/wallets.repository'; -import { IWalletsRepository } from '@/modules/wallets/domain/wallets.repository.interface'; @Module({ imports: [ @@ -49,6 +48,7 @@ import { IWalletsRepository } from '@/modules/wallets/domain/wallets.repository. ]), forwardRef(() => AuthModule), forwardRef(() => UsersModule), + UserIdentityResolverModule, ], controllers: [ AddressBooksController, @@ -85,10 +85,6 @@ import { IWalletsRepository } from '@/modules/wallets/domain/wallets.repository. provide: IAddressBookRequestsRepository, useClass: AddressBookRequestsRepository, }, - { - provide: IWalletsRepository, - useClass: WalletsRepository, - }, ], exports: [ ISpacesRepository, diff --git a/src/modules/users/domain/__tests__/user-identity-resolver.service.spec.ts b/src/modules/users/domain/__tests__/user-identity-resolver.service.spec.ts new file mode 100644 index 0000000000..7f9d9bce7c --- /dev/null +++ b/src/modules/users/domain/__tests__/user-identity-resolver.service.spec.ts @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: FSL-1.1-MIT + +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; +import { User as DbUser } from '@/modules/users/datasources/entities/users.entity.db'; +import { UserIdentityResolverService } from '@/modules/users/domain/user-identity-resolver.service'; +import { IUsersRepository } from '@/modules/users/domain/users.repository.interface'; +import { Wallet as DbWallet } from '@/modules/wallets/datasources/entities/wallets.entity.db'; +import { IWalletsRepository } from '@/modules/wallets/domain/wallets.repository.interface'; + +const mockUsersRepository = jest.mocked({ + find: jest.fn(), +} as unknown as IUsersRepository); + +const mockWalletsRepository = jest.mocked({ + find: jest.fn(), +} as unknown as IWalletsRepository); + +const buildUser = (overrides: Partial): DbUser => + ({ + id: 0, + email: null, + ...overrides, + }) as DbUser; + +const buildWallet = (overrides: Partial): DbWallet => + ({ + id: 0, + ...overrides, + }) as DbWallet; + +describe('UserIdentityResolverService', () => { + let service: UserIdentityResolverService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new UserIdentityResolverService( + mockUsersRepository, + mockWalletsRepository, + ); + }); + + it('returns an empty map for empty input', async () => { + const result = await service.resolveMany([]); + expect(result.size).toBe(0); + expect(mockUsersRepository.find).not.toHaveBeenCalled(); + }); + + it('prefers wallet address when present', async () => { + const wallet = getAddress(faker.finance.ethereumAddress()); + mockUsersRepository.find.mockResolvedValue([ + buildUser({ id: 1, email: 'a@b.com' }), + ]); + mockWalletsRepository.find.mockResolvedValue([ + buildWallet({ user: buildUser({ id: 1 }), address: wallet }), + ]); + + const result = await service.resolveMany([1]); + expect(result.get(1)).toBe(wallet); + }); + + it('falls back to email when no wallet', async () => { + mockUsersRepository.find.mockResolvedValue([ + buildUser({ id: 2, email: 'c@d.com' }), + ]); + mockWalletsRepository.find.mockResolvedValue([]); + + const result = await service.resolveMany([2]); + expect(result.get(2)).toBe('c@d.com'); + }); + + it('returns "Unknown user" when no wallet and no email', async () => { + mockUsersRepository.find.mockResolvedValue([ + buildUser({ id: 3, email: null }), + ]); + mockWalletsRepository.find.mockResolvedValue([]); + + const result = await service.resolveMany([3]); + expect(result.get(3)).toBe('Unknown user'); + }); + + it('picks the lowest-id wallet when a user has multiple', async () => { + const earlierWallet = getAddress(faker.finance.ethereumAddress()); + const laterWallet = getAddress(faker.finance.ethereumAddress()); + mockUsersRepository.find.mockResolvedValue([ + buildUser({ id: 7, email: null }), + ]); + // Return wallets in the "wrong" order to verify the resolver sorts them. + mockWalletsRepository.find.mockResolvedValue([ + buildWallet({ id: 20, user: buildUser({ id: 7 }), address: laterWallet }), + buildWallet({ + id: 5, + user: buildUser({ id: 7 }), + address: earlierWallet, + }), + ]); + + const result = await service.resolveMany([7]); + expect(result.get(7)).toBe(earlierWallet); + }); + + it('omits user IDs whose user no longer exists', async () => { + mockUsersRepository.find.mockResolvedValue([]); + mockWalletsRepository.find.mockResolvedValue([]); + + const result = await service.resolveMany([99]); + expect(result.has(99)).toBe(false); + }); + + it('exposes label constants', () => { + expect(UserIdentityResolverService.UNKNOWN_USER_LABEL).toBe('Unknown user'); + expect(UserIdentityResolverService.DELETED_USER_LABEL).toBe('Deleted user'); + }); +}); diff --git a/src/modules/users/domain/user-identity-resolver.module.ts b/src/modules/users/domain/user-identity-resolver.module.ts new file mode 100644 index 0000000000..80cc74f3a0 --- /dev/null +++ b/src/modules/users/domain/user-identity-resolver.module.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: FSL-1.1-MIT + +import { forwardRef, Module } from '@nestjs/common'; +import { UserIdentityResolverService } from '@/modules/users/domain/user-identity-resolver.service'; +import { UsersModule } from '@/modules/users/users.module'; +import { WalletsModule } from '@/modules/wallets/wallets.module'; + +@Module({ + imports: [forwardRef(() => UsersModule), WalletsModule], + providers: [UserIdentityResolverService], + exports: [UserIdentityResolverService], +}) +export class UserIdentityResolverModule {} diff --git a/src/modules/users/domain/user-identity-resolver.service.ts b/src/modules/users/domain/user-identity-resolver.service.ts new file mode 100644 index 0000000000..aad6696699 --- /dev/null +++ b/src/modules/users/domain/user-identity-resolver.service.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: FSL-1.1-MIT + +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { IUsersRepository } from '@/modules/users/domain/users.repository.interface'; +import { IWalletsRepository } from '@/modules/wallets/domain/wallets.repository.interface'; + +@Injectable() +export class UserIdentityResolverService { + public static readonly DELETED_USER_LABEL = 'Deleted user'; + public static readonly UNKNOWN_USER_LABEL = 'Unknown user'; + + constructor( + @Inject(IUsersRepository) + private readonly usersRepository: IUsersRepository, + @Inject(IWalletsRepository) + private readonly walletsRepository: IWalletsRepository, + ) {} + + /** + * Resolves a user-display string per user ID. + * Order: first wallet address → email → "Unknown user". + * IDs whose user is missing are omitted; callers should treat absence + * as "Deleted user" (use {@link UserIdentityResolverService.DELETED_USER_LABEL}). + */ + public async resolveMany( + userIds: ReadonlyArray, + ): Promise> { + const unique = [...new Set(userIds)]; + if (unique.length === 0) return new Map(); + + const [users, wallets] = await Promise.all([ + this.usersRepository.find({ id: In(unique) }), + this.walletsRepository.find({ + where: { user: { id: In(unique) } }, + relations: { user: true }, + }), + ]); + + // Sort wallets by id ascending so users with multiple wallets always + // resolve to the same display address across runs. + const sortedWallets = [...wallets].sort((a, b) => a.id - b.id); + const walletByUserId = new Map(); + for (const wallet of sortedWallets) { + if (!walletByUserId.has(wallet.user.id)) { + walletByUserId.set(wallet.user.id, wallet.address); + } + } + + return new Map( + users.map((user): [number, string] => [ + user.id, + walletByUserId.get(user.id) ?? + user.email ?? + UserIdentityResolverService.UNKNOWN_USER_LABEL, + ]), + ); + } +}