diff --git a/backend/spec/plugins/customers/customer.resolver.spec.ts b/backend/spec/plugins/customers/customer.resolver.spec.ts new file mode 100644 index 00000000..f3a34eeb --- /dev/null +++ b/backend/spec/plugins/customers/customer.resolver.spec.ts @@ -0,0 +1,195 @@ +/** + * CustomerResolver tests + * + * Covers duplicate prevention and routing to the correct creation path. + */ + +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { + Channel, + Customer, + CustomerEvent, + CustomerService, + EventBus, + RequestContext, + User, +} from '@vendure/core'; +import { IsNull } from 'typeorm'; +import { CustomerResolver } from '../../../src/plugins/customers/customer.resolver'; +import { CustomerCreationService } from '../../../src/services/customers/customer-creation.service'; +import { CustomerLookupService } from '../../../src/services/customers/customer-lookup.service'; + +describe('CustomerResolver', () => { + let resolver: CustomerResolver; + + const mockCustomerService = { + create: jest.fn(), + }; + + const mockCustomerCreationService = { + create: jest.fn(), + }; + + const mockCustomerLookupService = { + findCustomerByPhoneIncludingDeleted: jest.fn(), + }; + + const mockEventBus = { + publish: jest.fn().mockResolvedValue(undefined as never), + }; + + const mockConnection: any = { + getRepository: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + resolver = new CustomerResolver( + mockCustomerService as unknown as CustomerService, + mockCustomerCreationService as unknown as CustomerCreationService, + mockCustomerLookupService as unknown as CustomerLookupService, + mockEventBus as unknown as EventBus, + mockConnection as any + ); + }); + + const ctx = { + channelId: 1, + channel: { id: 1 } as Channel, + } as unknown as RequestContext; + + it('routes to CustomerCreationService with existing user when phone belongs to an admin', async () => { + const normalizedPhone = '0712345678'; + const adminUser = { + id: 100, + identifier: normalizedPhone, + roles: [], + } as unknown as User; + const savedCustomer = { + id: 'cust-1', + firstName: 'Jane', + lastName: 'Doe', + } as Customer; + + mockCustomerLookupService.findCustomerByPhoneIncludingDeleted.mockResolvedValue(null as never); + + const userRepo = { + findOne: jest.fn().mockResolvedValue(adminUser as never), + }; + + mockConnection.getRepository.mockImplementation((_ctx: any, entity: any) => { + if (entity === User) return userRepo; + return { findOne: jest.fn(), save: jest.fn() }; + }); + mockCustomerCreationService.create.mockResolvedValue(savedCustomer as never); + + const result = await resolver.createCustomerSafe(ctx, { + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + phoneNumber: normalizedPhone, + }); + + expect(result).toBe(savedCustomer); + expect(userRepo.findOne).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ identifier: normalizedPhone, deletedAt: IsNull() }), + relations: ['roles'], + }) + ); + expect(mockCustomerCreationService.create).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + phoneNumber: normalizedPhone, + }), + adminUser + ); + expect(mockCustomerService.create).not.toHaveBeenCalled(); + }); + + it('routes to CustomerCreationService without existing user when phone is new', async () => { + const normalizedPhone = '0712345678'; + const createdCustomer = { + id: 'cust-2', + firstName: 'Jane', + lastName: 'Doe', + } as Customer; + + mockCustomerLookupService.findCustomerByPhoneIncludingDeleted.mockResolvedValue(null as never); + + const userRepo = { + findOne: jest.fn().mockResolvedValue(null as never), + }; + + mockConnection.getRepository.mockImplementation((_ctx: any, entity: any) => { + if (entity === User) return userRepo; + return { findOne: jest.fn(), save: jest.fn() }; + }); + mockCustomerCreationService.create.mockResolvedValue(createdCustomer as never); + + const result = await resolver.createCustomerSafe(ctx, { + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + phoneNumber: normalizedPhone, + }); + + expect(result).toBe(createdCustomer); + expect(mockCustomerCreationService.create).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + phoneNumber: normalizedPhone, + }) + ); + expect(mockCustomerService.create).not.toHaveBeenCalled(); + }); + + it('updates an existing customer and publishes an updated event', async () => { + const normalizedPhone = '0712345678'; + const existingCustomer = { + id: 'cust-3', + firstName: 'Old', + lastName: 'Name', + emailAddress: 'old@example.com', + phoneNumber: normalizedPhone, + deletedAt: null, + } as Customer; + const updatedCustomer = { + ...existingCustomer, + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + } as Customer; + + mockCustomerLookupService.findCustomerByPhoneIncludingDeleted.mockResolvedValue( + existingCustomer as never + ); + + const customerRepo = { + save: jest.fn().mockResolvedValue(updatedCustomer as never), + }; + + mockConnection.getRepository.mockImplementation((_ctx: any, entity: any) => { + if (entity === Customer) return customerRepo; + return { findOne: jest.fn(), save: jest.fn() }; + }); + + const result = await resolver.createCustomerSafe(ctx, { + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + phoneNumber: normalizedPhone, + }); + + expect(result).toBe(updatedCustomer); + expect(mockEventBus.publish).toHaveBeenCalled(); + const event = (mockEventBus.publish as jest.Mock).mock.calls[0][0] as CustomerEvent; + expect(event.type).toBe('updated'); + }); +}); diff --git a/backend/spec/services/channels/channel-admin.service.spec.ts b/backend/spec/services/channels/channel-admin.service.spec.ts index 3fc9784d..ce431826 100644 --- a/backend/spec/services/channels/channel-admin.service.spec.ts +++ b/backend/spec/services/channels/channel-admin.service.spec.ts @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { BadRequestException } from '@nestjs/common'; import { Administrator, Channel, RequestContext, Role, User } from '@vendure/core'; +import { CUSTOMER_ROLE_CODE } from '@vendure/common/lib/shared-constants'; import { ChannelAdminService } from '../../../src/services/channels/channel-admin.service'; describe('ChannelAdminService', () => { @@ -57,6 +58,10 @@ describe('ChannelAdminService', () => { getLimit: jest.fn().mockResolvedValue(undefined as never), }; + const mockSessionService = { + deleteSessionsByUser: jest.fn().mockResolvedValue(undefined as never), + }; + beforeEach(() => { jest.clearAllMocks(); service = new ChannelAdminService( @@ -69,7 +74,8 @@ describe('ChannelAdminService', () => { mockRoleTemplateService as any, mockPasswordCipher as any, mockEventBus as any, - mockEntitlementService as any + mockEntitlementService as any, + mockSessionService as any ); }); @@ -155,8 +161,18 @@ describe('ChannelAdminService', () => { const userRepo = { findOne: jest.fn().mockResolvedValue(createUserWithChannel(2) as never), }; + const activeAdmin = { + id: '1', + firstName: 'Jane', + lastName: 'Doe', + emailAddress: '0712345678', + user: { id: 100 }, + deletedAt: null, + } as unknown as Administrator; mockConnection.getRepository.mockImplementation((_ctx: any, entity: any) => { if (entity === User) return userRepo; + if (entity === Administrator) + return { findOne: jest.fn().mockResolvedValue(activeAdmin as never), save: jest.fn() }; return { findOne: jest.fn().mockResolvedValue(null as never), save: jest.fn() }; }); @@ -183,8 +199,18 @@ describe('ChannelAdminService', () => { const userRepo = { findOne: jest.fn().mockResolvedValue(createUserWithChannel(2) as never), }; + const activeAdmin = { + id: '1', + firstName: 'Jane', + lastName: 'Doe', + emailAddress: '0712345678', + user: { id: 100 }, + deletedAt: null, + } as unknown as Administrator; mockConnection.getRepository.mockImplementation((_ctx: any, entity: any) => { if (entity === User) return userRepo; + if (entity === Administrator) + return { findOne: jest.fn().mockResolvedValue(activeAdmin as never), save: jest.fn() }; return { findOne: jest.fn().mockResolvedValue(null as never), save: jest.fn() }; }); @@ -316,13 +342,13 @@ describe('ChannelAdminService', () => { }); describe('re-add after disable', () => { - it('re-invite by phone after disable succeeds and returns an Administrator', async () => { + it('re-invite by phone after disable reactivates the soft-deleted Administrator', async () => { const ctx = { channelId: 2 } as unknown as RequestContext; const existingUser = { id: 100, identifier: '0712345678', verified: true, - roles: [], // cleared by disable + roles: [], // channel role was stripped by disable } as unknown as User; const userRepo = { findOne: jest.fn().mockResolvedValue(existingUser as never), @@ -335,12 +361,13 @@ describe('ChannelAdminService', () => { select: jest.fn().mockReturnThis(), getRawMany: jest.fn().mockResolvedValue([{ admin_id: 1 }] as never), }; - const newAdmin = { - id: '99', + const softDeletedAdmin = { + id: '42', firstName: 'Jane', lastName: 'Doe', emailAddress: '0712345678', user: existingUser, + deletedAt: new Date(), } as unknown as Administrator; mockChannelService.findOne.mockResolvedValue({ id: 2, @@ -356,11 +383,8 @@ describe('ChannelAdminService', () => { code: 'channel-2-tpl-1', channels: [{ id: 2 }], } as never); - const adminFindOne = jest - .fn() - .mockResolvedValue(null as never) // no existing Administrator (was removed on disable) - .mockResolvedValueOnce(null as never); - const adminSave = jest.fn().mockResolvedValue(newAdmin as never); + const adminFindOne = jest.fn().mockResolvedValue(softDeletedAdmin as never); + const adminSave = jest.fn().mockResolvedValue(softDeletedAdmin as never); mockConnection.getRepository.mockImplementation((_ctx: any, entity: any) => { if (entity === User) return userRepo; if (entity === Administrator) return { findOne: adminFindOne, save: adminSave }; @@ -379,13 +403,143 @@ describe('ChannelAdminService', () => { }); expect(result).toBeDefined(); - expect(result.id).toBe('99'); + expect(result.id).toBe('42'); expect(result.firstName).toBe('Jane'); expect(result.lastName).toBe('Doe'); expect(adminSave).toHaveBeenCalled(); + const saved = (adminSave as jest.Mock).mock.calls[0][0] as Administrator; + expect(saved.deletedAt).toBeNull(); expect(mockEventBus.publish).toHaveBeenCalled(); const event = (mockEventBus.publish as jest.Mock).mock.calls[0][0] as { type: string }; - expect(event.type).toBe('created'); + expect(event.type).toBe('updated'); + }); + }); + + describe('disableChannelAdministrator', () => { + const makeUserWithRoles = (roles: Role[]): User => + ({ + id: 100, + identifier: '0712345678', + verified: true, + roles, + }) as unknown as User; + + const makeRole = (id: number, channelId: number | string): Role => + ({ + id, + code: `channel-${channelId}-admin`, + channels: [{ id: channelId } as Channel], + }) as unknown as Role; + + const setupDisableMocks = (options: { + administrator?: Administrator; + user: User; + relationRemove?: jest.Mock; + adminSave?: jest.Mock; + }) => { + const administrator = + options.administrator || + ({ + id: '42', + firstName: 'Jane', + lastName: 'Doe', + user: { id: 100 }, + deletedAt: null, + } as unknown as Administrator); + + mockAdministratorService.findOne.mockResolvedValue(administrator as never); + + const relationRemove = + options.relationRemove || jest.fn().mockResolvedValue(undefined as never); + const userRepo = { + findOne: jest.fn().mockResolvedValue(options.user as never), + createQueryBuilder: jest.fn().mockReturnValue({ + relation: jest.fn().mockReturnValue({ + of: jest.fn().mockReturnValue({ + remove: relationRemove, + }), + }), + }), + }; + + const adminSave = options.adminSave || jest.fn().mockResolvedValue(administrator as never); + const adminRepo = { + findOne: jest.fn().mockResolvedValue(administrator as never), + save: adminSave, + }; + + mockConnection.getRepository.mockImplementation((_ctx: any, entity: any) => { + if (entity === User) return userRepo; + if (entity === Administrator) return adminRepo; + return { findOne: jest.fn(), save: jest.fn() }; + }); + + return { relationRemove, adminSave }; + }; + + it('rejects disabling an administrator that does not belong to the channel', async () => { + const ctx = { channelId: 2 } as unknown as RequestContext; + const user = makeUserWithRoles([makeRole(1, 3)]); + setupDisableMocks({ user }); + + await expect(service.disableChannelAdministrator(ctx, '42')).rejects.toThrow( + 'does not belong to this channel' + ); + }); + + it('removes only this channel role and preserves other channel access', async () => { + const ctx = { channelId: 2 } as unknown as RequestContext; + const roleOnChannel2 = makeRole(1, 2); + const roleOnChannel3 = makeRole(2, 3); + const user = makeUserWithRoles([roleOnChannel2, roleOnChannel3]); + const { relationRemove, adminSave } = setupDisableMocks({ user }); + + const result = await service.disableChannelAdministrator(ctx, '42'); + + expect(result.success).toBe(true); + expect(result.message).toBe('Administrator removed from this channel'); + expect(relationRemove).toHaveBeenCalledWith([roleOnChannel2.id]); + expect(adminSave).not.toHaveBeenCalled(); + expect(mockSessionService.deleteSessionsByUser).toHaveBeenCalledWith(ctx, user); + }); + + it('soft-deletes the Administrator when no channel admin roles remain', async () => { + const ctx = { channelId: 2 } as unknown as RequestContext; + const user = makeUserWithRoles([makeRole(1, 2)]); + const { relationRemove, adminSave } = setupDisableMocks({ user }); + + const result = await service.disableChannelAdministrator(ctx, '42'); + + expect(result.success).toBe(true); + expect(result.message).toBe('Administrator disabled successfully'); + expect(relationRemove).toHaveBeenCalledWith([user.roles[0].id]); + expect(adminSave).toHaveBeenCalled(); + const saved = (adminSave as jest.Mock).mock.calls[0][0] as Administrator; + expect(saved.deletedAt).toBeInstanceOf(Date); + expect(mockSessionService.deleteSessionsByUser).toHaveBeenCalledWith(ctx, user); + }); + + it('preserves the customer role for dual-role users (admin + customer)', async () => { + const ctx = { channelId: 2 } as unknown as RequestContext; + const adminRole = makeRole(1, 2); + const customerRole = { + id: 99, + code: CUSTOMER_ROLE_CODE, + channels: [], + } as unknown as Role; + const user = makeUserWithRoles([adminRole, customerRole]); + const { relationRemove, adminSave } = setupDisableMocks({ user }); + + const result = await service.disableChannelAdministrator(ctx, '42'); + + // The channel-less customer role must not be treated as superadmin access, + // must not be stripped, and must not count as remaining admin access. + expect(result.success).toBe(true); + expect(relationRemove).toHaveBeenCalledWith([adminRole.id]); + expect(adminSave).toHaveBeenCalled(); + const saved = (adminSave as jest.Mock).mock.calls[0][0] as Administrator; + expect(saved.deletedAt).toBeInstanceOf(Date); + expect(mockSessionService.deleteSessionsByUser).toHaveBeenCalledWith(ctx, user); }); }); }); diff --git a/backend/spec/services/customers/customer-creation.service.spec.ts b/backend/spec/services/customers/customer-creation.service.spec.ts new file mode 100644 index 00000000..e8410dd6 --- /dev/null +++ b/backend/spec/services/customers/customer-creation.service.spec.ts @@ -0,0 +1,392 @@ +/** + * CustomerCreationService tests + * + * Ensures the admin-as-customer path creates a Customer for an existing User, + * assigns the customer role, and publishes the expected Vendure events. + */ + +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { + Channel, + Customer, + CustomerEvent, + CustomerService, + EventBus, + RequestContext, + Role, + RoleService, + User, +} from '@vendure/core'; +import { HistoryEntryType } from '@vendure/common/lib/generated-types'; +import { CustomerCreationService } from '../../../src/services/customers/customer-creation.service'; + +describe('CustomerCreationService', () => { + let service: CustomerCreationService; + + const mockCustomerService = { + create: jest.fn(), + }; + + const customerRole = { id: 999, code: 'customer' } as Role; + const mockRoleService = { + getCustomerRole: jest.fn().mockResolvedValue(customerRole as never), + }; + + const mockEventBus = { + publish: jest.fn().mockResolvedValue(undefined as never), + }; + + const mockConnection: any = { + getRepository: jest.fn(), + }; + + const mockCustomFieldRelationService = { + updateRelations: jest.fn().mockResolvedValue(undefined as never), + }; + + const mockHistoryService = { + createHistoryEntryForCustomer: jest.fn().mockResolvedValue(undefined as never), + }; + + const makeUserRepo = (relationAdd: jest.Mock) => ({ + createQueryBuilder: jest.fn().mockReturnValue({ + relation: jest.fn().mockReturnValue({ + of: jest.fn().mockReturnValue({ + add: relationAdd, + }), + }), + }), + }); + + const makeCustomerRepo = (saveResult: Customer) => ({ + findOne: jest.fn().mockResolvedValue(null as never), + save: jest.fn().mockResolvedValue(saveResult as never), + createQueryBuilder: jest.fn().mockReturnValue({ + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null as never), + }), + }); + + beforeEach(() => { + jest.clearAllMocks(); + service = new CustomerCreationService( + mockCustomerService as unknown as CustomerService, + mockRoleService as unknown as RoleService, + mockEventBus as unknown as EventBus, + mockConnection as any, + mockCustomFieldRelationService as any, + mockHistoryService as any + ); + }); + + const ctx = { + channelId: 1, + channel: { id: 1 } as Channel, + } as unknown as RequestContext; + + it('creates a Customer for an existing User and adds the customer role', async () => { + const adminUser = { + id: 100, + identifier: '0712345678', + verified: true, + roles: [{ id: 1, code: 'channel-1-admin' } as Role], + } as User; + const savedCustomer = { + id: 'cust-1', + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + phoneNumber: '0712345678', + user: adminUser, + } as Customer; + + const relationAdd = jest.fn().mockResolvedValue(undefined as never); + const customerRepo = makeCustomerRepo(savedCustomer); + + mockConnection.getRepository.mockImplementation((_ctx: any, entity: any) => { + if (entity === User) return makeUserRepo(relationAdd); + if (entity === Customer) return customerRepo; + return { findOne: jest.fn(), save: jest.fn() }; + }); + + const result = await service.create( + ctx, + { + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + phoneNumber: '0712345678', + }, + adminUser + ); + + expect(result).toBe(savedCustomer); + expect(relationAdd).toHaveBeenCalledWith(customerRole.id); + expect(customerRepo.save).toHaveBeenCalled(); + const saved = (customerRepo.save as jest.Mock).mock.calls[0][0] as Customer; + expect(saved.user).toBe(adminUser); + expect(saved.channels).toEqual([ctx.channel]); + expect(mockEventBus.publish).toHaveBeenCalled(); + const event = (mockEventBus.publish as jest.Mock).mock.calls[0][0] as CustomerEvent; + expect(event.type).toBe('created'); + expect(event.entity).toBe(savedCustomer); + expect(mockCustomFieldRelationService.updateRelations).toHaveBeenCalledWith( + ctx, + Customer, + expect.objectContaining({ emailAddress: 'customer.0712345678@pos.local' }), + savedCustomer + ); + expect(mockHistoryService.createHistoryEntryForCustomer).toHaveBeenCalledWith( + expect.objectContaining({ + ctx, + customerId: savedCustomer.id, + type: HistoryEntryType.CUSTOMER_REGISTERED, + }) + ); + expect(mockHistoryService.createHistoryEntryForCustomer).toHaveBeenCalledWith( + expect.objectContaining({ + ctx, + customerId: savedCustomer.id, + type: HistoryEntryType.CUSTOMER_VERIFIED, + }) + ); + }); + + it('does not re-add the customer role when the User already has it', async () => { + const adminUser = { + id: 100, + identifier: '0712345678', + roles: [customerRole], + } as User; + const savedCustomer = { + id: 'cust-1', + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + phoneNumber: '0712345678', + user: adminUser, + } as Customer; + + const relationAdd = jest.fn().mockResolvedValue(undefined as never); + const customerRepo = makeCustomerRepo(savedCustomer); + + mockConnection.getRepository.mockImplementation((_ctx: any, entity: any) => { + if (entity === User) return makeUserRepo(relationAdd); + if (entity === Customer) return customerRepo; + return { findOne: jest.fn(), save: jest.fn() }; + }); + + await service.create( + ctx, + { + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + phoneNumber: '0712345678', + }, + adminUser + ); + + expect(relationAdd).not.toHaveBeenCalled(); + expect(mockCustomFieldRelationService.updateRelations).toHaveBeenCalled(); + expect(mockHistoryService.createHistoryEntryForCustomer).toHaveBeenCalledWith( + expect.objectContaining({ type: HistoryEntryType.CUSTOMER_REGISTERED }) + ); + expect(mockHistoryService.createHistoryEntryForCustomer).not.toHaveBeenCalledWith( + expect.objectContaining({ type: HistoryEntryType.CUSTOMER_VERIFIED }) + ); + }); + + it('delegates to Vendure CustomerService.create when no existing User is supplied', async () => { + const createdCustomer = { + id: 'cust-2', + firstName: 'Jane', + lastName: 'Doe', + } as Customer; + + mockCustomerService.create.mockResolvedValue(createdCustomer as never); + + const result = await service.create(ctx, { + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + phoneNumber: '0712345678', + }); + + expect(result).toBe(createdCustomer); + expect(mockCustomerService.create).toHaveBeenCalledWith(ctx, { + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + phoneNumber: '0712345678', + }); + expect(mockEventBus.publish).not.toHaveBeenCalled(); + expect(mockCustomFieldRelationService.updateRelations).not.toHaveBeenCalled(); + expect(mockHistoryService.createHistoryEntryForCustomer).not.toHaveBeenCalled(); + }); + + it('throws when Vendure CustomerService.create returns an error result', async () => { + mockCustomerService.create.mockResolvedValue({ + errorCode: 'EmailAddressConflictError', + message: 'Email address already exists', + } as never); + + await expect( + service.create(ctx, { + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + phoneNumber: '0712345678', + }) + ).rejects.toThrow('Email address already exists'); + }); + + it('throws when an active customer with the same email already exists in the channel', async () => { + const adminUser = { + id: 100, + identifier: '0712345678', + roles: [{ id: 1, code: 'channel-1-admin' } as Role], + } as User; + const existingInChannel = { + id: 'cust-existing', + emailAddress: 'customer.0712345678@pos.local', + } as unknown as Customer; + + const relationAdd = jest.fn().mockResolvedValue(undefined as never); + const customerRepo = makeCustomerRepo({} as Customer); + customerRepo.createQueryBuilder = jest.fn().mockReturnValue({ + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(existingInChannel as never), + }); + + mockConnection.getRepository.mockImplementation((_ctx: any, entity: any) => { + if (entity === User) return makeUserRepo(relationAdd); + if (entity === Customer) return customerRepo; + return { findOne: jest.fn(), save: jest.fn() }; + }); + + await expect( + service.create( + ctx, + { + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + phoneNumber: '0712345678', + }, + adminUser + ) + ).rejects.toThrow('Email address already exists'); + }); + + it('throws when the same email belongs to an active customer of a different user', async () => { + const adminUser = { + id: 100, + identifier: '0712345678', + roles: [{ id: 1, code: 'channel-1-admin' } as Role], + } as User; + const existingCustomer = { + id: 'cust-other-user', + emailAddress: 'customer.0712345678@pos.local', + user: { id: 999 }, + channels: [], + } as unknown as Customer; + + const relationAdd = jest.fn().mockResolvedValue(undefined as never); + const customerRepo = makeCustomerRepo({} as Customer); + customerRepo.findOne = jest.fn().mockResolvedValue(existingCustomer as never); + + mockConnection.getRepository.mockImplementation((_ctx: any, entity: any) => { + if (entity === User) return makeUserRepo(relationAdd); + if (entity === Customer) return customerRepo; + return { findOne: jest.fn(), save: jest.fn() }; + }); + + await expect( + service.create( + ctx, + { + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + phoneNumber: '0712345678', + }, + adminUser + ) + ).rejects.toThrow('Email address already exists'); + }); + + it('adds the current channel to an existing customer of the same user instead of duplicating', async () => { + const adminUser = { + id: 100, + identifier: '0712345678', + roles: [{ id: 1, code: 'channel-1-admin' } as Role], + } as User; + const existingCustomer = { + id: 'cust-same-user', + firstName: 'Old', + lastName: 'Name', + emailAddress: 'customer.0712345678@pos.local', + phoneNumber: '0711111111', + user: adminUser, + channels: [] as Channel[], + customFields: { existing: true }, + } as unknown as Customer; + const updatedCustomer = { + ...existingCustomer, + firstName: 'Jane', + lastName: 'Doe', + channels: [ctx.channel], + } as Customer; + + const relationAdd = jest.fn().mockResolvedValue(undefined as never); + const customerRepo = makeCustomerRepo(updatedCustomer); + customerRepo.findOne = jest.fn().mockResolvedValue(existingCustomer as never); + + mockConnection.getRepository.mockImplementation((_ctx: any, entity: any) => { + if (entity === User) return makeUserRepo(relationAdd); + if (entity === Customer) return customerRepo; + return { findOne: jest.fn(), save: jest.fn() }; + }); + + const result = await service.create( + ctx, + { + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + phoneNumber: '0712345678', + customFields: { new: true }, + }, + adminUser + ); + + expect(result).toBe(updatedCustomer); + expect(relationAdd).not.toHaveBeenCalled(); + expect(customerRepo.save).toHaveBeenCalled(); + const saved = (customerRepo.save as jest.Mock).mock.calls[0][0] as Customer; + expect(saved.channels).toEqual([ctx.channel]); + expect(saved.customFields).toEqual({ existing: true, new: true }); + + const event = (mockEventBus.publish as jest.Mock).mock.calls[0][0] as CustomerEvent; + expect(event.type).toBe('updated'); + expect(event.entity).toBe(updatedCustomer); + expect(mockCustomFieldRelationService.updateRelations).toHaveBeenCalledWith( + ctx, + Customer, + expect.objectContaining({ emailAddress: 'customer.0712345678@pos.local' }), + updatedCustomer + ); + expect(mockHistoryService.createHistoryEntryForCustomer).toHaveBeenCalledWith( + expect.objectContaining({ + ctx, + customerId: updatedCustomer.id, + type: HistoryEntryType.CUSTOMER_DETAIL_UPDATED, + }) + ); + }); +}); diff --git a/backend/spec/services/customers/customer-lifecycle.service.spec.ts b/backend/spec/services/customers/customer-lifecycle.service.spec.ts new file mode 100644 index 00000000..fd76b12a --- /dev/null +++ b/backend/spec/services/customers/customer-lifecycle.service.spec.ts @@ -0,0 +1,211 @@ +/** + * CustomerLifecycleService unit tests + * + * Ensures customers whose User is shared with an Administrator never have that + * User rewritten (email updates) or soft-deleted (customer deletion), while + * regular customers keep Vendure's stock behavior. + */ + +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { NotFoundException } from '@nestjs/common'; +import { Administrator, Customer, RequestContext, User, UserInputError } from '@vendure/core'; +import { CUSTOMER_ROLE_CODE } from '@vendure/common/lib/shared-constants'; +import { DeletionResult } from '@vendure/common/lib/generated-types'; +import { CustomerLifecycleService } from '../../../src/services/customers/customer-lifecycle.service'; + +describe('CustomerLifecycleService', () => { + let service: CustomerLifecycleService; + + const mockCustomerService = { + update: jest.fn(), + softDelete: jest.fn(), + }; + + const mockEventBus = { + publish: jest.fn().mockResolvedValue(undefined as never), + }; + + const mockConnection: any = { + getRepository: jest.fn(), + }; + + const mockCustomFieldRelationService = { + updateRelations: jest.fn().mockResolvedValue(undefined as never), + }; + + const mockHistoryService = { + createHistoryEntryForCustomer: jest.fn().mockResolvedValue(undefined as never), + }; + + const ctx = { channelId: 1, channel: { id: 1 } } as unknown as RequestContext; + + const customerRole = { id: 10, code: CUSTOMER_ROLE_CODE, channels: [] }; + const adminRole = { id: 11, code: 'channel-1-admin', channels: [{ id: 1 }] }; + + const makeCustomer = (roles: any[], userId = 100): Customer => + ({ + id: 'cust-1', + firstName: 'Jane', + lastName: 'Doe', + emailAddress: 'customer.0712345678@pos.local', + deletedAt: null, + user: { id: userId, identifier: '0712345678', roles } as unknown as User, + }) as unknown as Customer; + + /** + * Wire the connection mock. `customer` is returned by the Customer repo + * findOne; `activeAdmin` (default null) by the Administrator repo findOne. + */ + const setupConnection = (customer: Customer | null, activeAdmin: Administrator | null = null) => { + const customerRepo = { + findOne: jest.fn().mockResolvedValue(customer as never), + save: jest.fn().mockImplementation((c: any) => Promise.resolve(c)), + update: jest.fn().mockResolvedValue(undefined as never), + createQueryBuilder: jest.fn().mockReturnValue({ + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null as never), + }), + }; + const adminRepo = { + findOne: jest.fn().mockResolvedValue(activeAdmin as never), + }; + mockConnection.getRepository.mockImplementation((_ctx: any, entity: any) => { + if (entity === Customer) return customerRepo; + if (entity === Administrator) return adminRepo; + return { findOne: jest.fn(), save: jest.fn() }; + }); + return { customerRepo, adminRepo }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + service = new CustomerLifecycleService( + mockCustomerService as any, + mockEventBus as any, + mockConnection as any, + mockCustomFieldRelationService as any, + mockHistoryService as any + ); + }); + + describe('update', () => { + it('delegates to Vendure CustomerService for customer-owned users', async () => { + const customer = makeCustomer([customerRole]); + setupConnection(customer); + const updated = { ...customer, firstName: 'Janet' } as Customer; + mockCustomerService.update.mockResolvedValue(updated as never); + + const result = await service.update(ctx, { id: 'cust-1', firstName: 'Janet' }); + + expect(result).toBe(updated); + expect(mockCustomerService.update).toHaveBeenCalled(); + expect(mockEventBus.publish).not.toHaveBeenCalled(); + }); + + it('maps Vendure error results to UserInputError for customer-owned users', async () => { + const customer = makeCustomer([customerRole]); + setupConnection(customer); + mockCustomerService.update.mockResolvedValue({ + errorCode: 'EMAIL_ADDRESS_CONFLICT_ERROR', + message: 'Email address already exists', + } as never); + + await expect(service.update(ctx, { id: 'cust-1', firstName: 'Janet' })).rejects.toThrow( + UserInputError + ); + }); + + it('updates shared-user customers directly without touching CustomerService.update', async () => { + const customer = makeCustomer([customerRole, adminRole]); + const { customerRepo } = setupConnection(customer); + + const result = await service.update(ctx, { id: 'cust-1', firstName: 'Janet' }); + + expect(mockCustomerService.update).not.toHaveBeenCalled(); + expect(customerRepo.save).toHaveBeenCalled(); + const saved = (customerRepo.save as jest.Mock).mock.calls[0][0] as Customer; + expect(saved.firstName).toBe('Janet'); + // The shared User must never be modified: no identifier rewrite happens + // because CustomerService.update (which calls changeUserAndNativeIdentifier) + // is bypassed entirely. + expect(saved.user?.identifier).toBe('0712345678'); + expect(mockEventBus.publish).toHaveBeenCalled(); + const event = (mockEventBus.publish as jest.Mock).mock.calls[0][0] as { type: string }; + expect(event.type).toBe('updated'); + }); + + it('treats a user with only the customer role but an active Administrator as shared', async () => { + const customer = makeCustomer([customerRole]); + const activeAdmin = { + id: '42', + user: { id: 100 }, + deletedAt: null, + } as unknown as Administrator; + setupConnection(customer, activeAdmin); + + await service.update(ctx, { id: 'cust-1', firstName: 'Janet' }); + + expect(mockCustomerService.update).not.toHaveBeenCalled(); + }); + + it('rejects email changes that conflict with another customer in the channel', async () => { + const customer = makeCustomer([customerRole, adminRole]); + const { customerRepo } = setupConnection(customer); + customerRepo.createQueryBuilder.mockReturnValue({ + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ id: 'other' } as never), + }); + + await expect( + service.update(ctx, { id: 'cust-1', emailAddress: 'taken@example.com' }) + ).rejects.toThrow(UserInputError); + expect(customerRepo.save).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when the customer does not exist', async () => { + setupConnection(null); + await expect(service.update(ctx, { id: 'missing' })).rejects.toThrow(NotFoundException); + }); + }); + + describe('delete', () => { + it('delegates to Vendure softDelete for customer-owned users', async () => { + const customer = makeCustomer([customerRole]); + setupConnection(customer); + mockCustomerService.softDelete.mockResolvedValue({ result: DeletionResult.DELETED } as never); + + const result = await service.delete(ctx, 'cust-1'); + + expect(result.result).toBe(DeletionResult.DELETED); + expect(mockCustomerService.softDelete).toHaveBeenCalledWith(ctx, 'cust-1'); + }); + + it('soft-deletes only the Customer row for shared users', async () => { + const customer = makeCustomer([customerRole, adminRole]); + const { customerRepo } = setupConnection(customer); + + const result = await service.delete(ctx, 'cust-1'); + + expect(result.result).toBe(DeletionResult.DELETED); + // The shared User must survive: Vendure's softDelete (which soft-deletes + // the User) is bypassed and only the Customer row is marked deleted. + expect(mockCustomerService.softDelete).not.toHaveBeenCalled(); + expect(customerRepo.update).toHaveBeenCalledWith( + { id: customer.id }, + expect.objectContaining({ deletedAt: expect.any(Date) }) + ); + expect(mockEventBus.publish).toHaveBeenCalled(); + const event = (mockEventBus.publish as jest.Mock).mock.calls[0][0] as { type: string }; + expect(event.type).toBe('deleted'); + }); + + it('throws NotFoundException when the customer does not exist', async () => { + setupConnection(null); + await expect(service.delete(ctx, 'missing')).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/backend/src/infrastructure/audit/user-context.resolver.ts b/backend/src/infrastructure/audit/user-context.resolver.ts index 6a871b4f..e6da8e87 100644 --- a/backend/src/infrastructure/audit/user-context.resolver.ts +++ b/backend/src/infrastructure/audit/user-context.resolver.ts @@ -8,6 +8,7 @@ import { Administrator, AdministratorService, } from '@vendure/core'; +import { IsNull } from 'typeorm'; /** * User Context Resolver @@ -44,7 +45,7 @@ export class UserContextResolver { // Load administrator with roles relation const administrator = await this.connection.getRepository(ctx, Administrator).findOne({ - where: { user: { id: ctx.activeUserId } }, + where: { user: { id: ctx.activeUserId }, deletedAt: IsNull() }, relations: ['user', 'user.roles', 'user.roles.channels'], }); diff --git a/backend/src/plugins/audit/audit.resolver.ts b/backend/src/plugins/audit/audit.resolver.ts index 6a7af676..2a90fe5c 100644 --- a/backend/src/plugins/audit/audit.resolver.ts +++ b/backend/src/plugins/audit/audit.resolver.ts @@ -12,7 +12,6 @@ import { gql } from 'graphql-tag'; import { AuditLog } from '../../infrastructure/audit/audit-log.entity'; import { AuditService } from '../../infrastructure/audit/audit.service'; import { AuditTrailFilters } from '../../infrastructure/audit/audit.types'; - export const auditSchema = gql` extend type Query { auditLogs(options: AuditLogOptions): [AuditLog!]! @@ -124,9 +123,12 @@ export class AuditResolver { @Args('userId') userId: string ): Promise { if (!userId) return null; + // History lookups need to resolve the actor even after the administrator + // record has been soft-deleted, so include soft-deleted rows here. const administrator = await this.connection.getRepository(ctx, Administrator).findOne({ where: { user: { id: userId } }, relations: ['user', 'user.roles', 'user.roles.channels'], + withDeleted: true, }); return administrator ?? null; } diff --git a/backend/src/plugins/customers/customer.plugin.ts b/backend/src/plugins/customers/customer.plugin.ts index 6aa6aea8..75bd38a7 100644 --- a/backend/src/plugins/customers/customer.plugin.ts +++ b/backend/src/plugins/customers/customer.plugin.ts @@ -2,7 +2,9 @@ import { OnModuleInit } from '@nestjs/common'; import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { VENDURE_COMPATIBILITY_VERSION } from '../../constants/vendure-version.constants'; import { gql } from 'graphql-tag'; -import { CustomerResolver } from './customer.resolver'; +import { CustomerResolver, CustomerAdminResolver } from './customer.resolver'; +import { CustomerCreationService } from '../../services/customers/customer-creation.service'; +import { CustomerLifecycleService } from '../../services/customers/customer-lifecycle.service'; import { CustomerLookupService } from '../../services/customers/customer-lookup.service'; const CUSTOMER_SCHEMA = gql` @@ -16,9 +18,30 @@ const CUSTOMER_SCHEMA = gql` } `; +/** + * Admin API additions: update/delete mutations that protect shared users (a User + * that is both an Administrator and a Customer) from Vendure's stock behavior of + * rewriting the User identifier or soft-deleting the User. + * Admin-only because the shop schema does not define UpdateCustomerInput or + * DeletionResponse. + */ +const CUSTOMER_ADMIN_SCHEMA = gql` + extend type Mutation { + """ + Update a customer without rewriting the login identifier of a shared user. + """ + updateCustomerSafe(input: UpdateCustomerInput!): Customer! + + """ + Delete a customer without soft-deleting the shared user. + """ + deleteCustomerSafe(id: ID!): DeletionResponse! + } +`; + @VendurePlugin({ imports: [PluginCommonModule], - providers: [CustomerLookupService, CustomerResolver], + providers: [CustomerCreationService, CustomerLookupService, CustomerResolver], adminApiExtensions: { schema: CUSTOMER_SCHEMA, resolvers: [CustomerResolver], @@ -34,3 +57,23 @@ export class CustomerPlugin implements OnModuleInit { // Plugin initialization } } + +/** + * Customer Admin Plugin + * + * Separate plugin (same split as AuditCorePlugin/AuditPlugin) because any + * resolver listed in a plugin's providers leaks into BOTH the shop and admin + * schema builds whenever the plugin declares shopApiExtensions. Admin-only + * resolvers must therefore live in a plugin without shopApiExtensions. + * Must be registered in vendure-config next to CustomerPlugin. + */ +@VendurePlugin({ + imports: [PluginCommonModule], + providers: [CustomerLifecycleService, CustomerAdminResolver], + adminApiExtensions: { + schema: CUSTOMER_ADMIN_SCHEMA, + resolvers: [CustomerAdminResolver], + }, + compatibility: VENDURE_COMPATIBILITY_VERSION, +}) +export class CustomerAdminPlugin {} diff --git a/backend/src/plugins/customers/customer.resolver.ts b/backend/src/plugins/customers/customer.resolver.ts index 4143453e..74ff488a 100644 --- a/backend/src/plugins/customers/customer.resolver.ts +++ b/backend/src/plugins/customers/customer.resolver.ts @@ -4,15 +4,23 @@ import { Allow, Ctx, Customer, + CustomerEvent, CustomerService, + EventBus, Permission, RequestContext, TransactionalConnection, + User, UserInputError, } from '@vendure/core'; import { formatPhoneNumber } from '../../utils/phone.utils'; import { generateSentinelEmailFromPhone, getWalkInEmail } from '../../utils/email.utils'; +import { CustomerCreationService } from '../../services/customers/customer-creation.service'; +import { CustomerLifecycleService } from '../../services/customers/customer-lifecycle.service'; +import type { UpdateCustomerInput } from '../../services/customers/customer-lifecycle.service'; import { CustomerLookupService } from '../../services/customers/customer-lookup.service'; +import { DeletionResult } from '@vendure/common/lib/generated-types'; +import { IsNull } from 'typeorm'; /** * Input type for creating a customer @@ -38,7 +46,9 @@ export class CustomerResolver { constructor( private readonly customerService: CustomerService, + private readonly customerCreationService: CustomerCreationService, private readonly customerLookupService: CustomerLookupService, + private readonly eventBus: EventBus, private readonly connection: TransactionalConnection ) {} @@ -114,23 +124,88 @@ export class CustomerResolver { }; } - await customerRepo.save(existingCustomer); - this.logger.log(`Updated and returned existing customer ${existingCustomer.id}`); - return existingCustomer; + const updatedCustomer = await customerRepo.save(existingCustomer); + await this.eventBus.publish( + new CustomerEvent(ctx, updatedCustomer, 'updated', { + firstName: input.firstName, + lastName: input.lastName, + emailAddress: input.emailAddress, + phoneNumber: input.phoneNumber, + title: input.title, + customFields: input.customFields, + }) + ); + this.logger.log(`Updated and returned existing customer ${updatedCustomer.id}`); + return updatedCustomer; } } - // No existing customer found, proceed with normal creation + // No existing customer found. If the phone already belongs to an existing User + // (e.g. an admin), create a Customer record for that same user instead of a + // duplicate User. if (normalizedPhone) { - input.phoneNumber = normalizedPhone; + const existingUser = await this.connection.getRepository(ctx, User).findOne({ + where: { identifier: normalizedPhone, deletedAt: IsNull() }, + relations: ['roles'], + }); + if (existingUser) { + this.logger.log( + `Phone ${normalizedPhone} belongs to existing user ${existingUser.id}; creating Customer record for same user` + ); + return this.customerCreationService.create( + ctx, + { + ...input, + phoneNumber: normalizedPhone, + }, + existingUser + ); + } } - const result = await this.customerService.create(ctx, input); - // Handle error result (e.g., EmailAddressConflictError) - if ('errorCode' in result) { - throw new UserInputError(result.message || 'Failed to create customer'); + // No existing user found, proceed with Vendure's standard creation path. + if (normalizedPhone) { + input.phoneNumber = normalizedPhone; } + return this.customerCreationService.create(ctx, input); + } +} + +/** + * Admin-only customer mutations. Kept in a separate resolver (registered only + * for the admin API) because the shop schema does not define these mutations + * or their types. + */ +@Resolver() +@Injectable() +export class CustomerAdminResolver { + constructor(private readonly customerLifecycleService: CustomerLifecycleService) {} - return result; + /** + * Update a customer, protecting shared users (e.g. an admin who is also a + * customer) from Vendure's stock behavior of rewriting the User login + * identifier when the email changes. + */ + @Mutation() + @Allow(Permission.UpdateCustomer) + async updateCustomerSafe( + @Ctx() ctx: RequestContext, + @Args('input') input: UpdateCustomerInput + ): Promise { + return this.customerLifecycleService.update(ctx, input); + } + + /** + * Delete a customer, protecting shared users (e.g. an admin who is also a + * customer) from Vendure's stock behavior of soft-deleting the User along + * with the Customer. + */ + @Mutation() + @Allow(Permission.DeleteCustomer) + async deleteCustomerSafe( + @Ctx() ctx: RequestContext, + @Args('id') id: string + ): Promise<{ result: DeletionResult }> { + return this.customerLifecycleService.delete(ctx, id); } } diff --git a/backend/src/plugins/super-admin/pending-registrations.service.ts b/backend/src/plugins/super-admin/pending-registrations.service.ts index 3c68a8d7..eef18e2e 100644 --- a/backend/src/plugins/super-admin/pending-registrations.service.ts +++ b/backend/src/plugins/super-admin/pending-registrations.service.ts @@ -34,7 +34,7 @@ export class PendingRegistrationsService { for (const user of pending) { const administrator = await adminRepo.findOne({ - where: { user: { id: user.id } }, + where: { user: { id: user.id }, deletedAt: IsNull() }, relations: ['user'], }); if (!administrator) continue; diff --git a/backend/src/plugins/super-admin/platform-admin.service.ts b/backend/src/plugins/super-admin/platform-admin.service.ts index 3dc5dbaa..702db531 100644 --- a/backend/src/plugins/super-admin/platform-admin.service.ts +++ b/backend/src/plugins/super-admin/platform-admin.service.ts @@ -1,5 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { Administrator, RequestContext, TransactionalConnection, User } from '@vendure/core'; +import { IsNull } from 'typeorm'; export interface PlatformAdministratorDto { id: string; @@ -61,6 +62,7 @@ export class PlatformAdminService { .innerJoinAndSelect('user.roles', 'role') .innerJoinAndSelect('role.channels', 'channel') .where('channel.id = :channelId', { channelId: channelIdNum }) + .andWhere('admin.deletedAt IS NULL') .andWhere('user.deletedAt IS NULL') .getMany(); @@ -118,7 +120,8 @@ export class PlatformAdminService { .innerJoinAndSelect('admin.user', 'user') .innerJoinAndSelect('user.roles', 'role') .leftJoinAndSelect('role.channels', 'channel') - .where('user.deletedAt IS NULL') + .where('admin.deletedAt IS NULL') + .andWhere('user.deletedAt IS NULL') .orderBy('admin.id', 'ASC') .getMany(); @@ -187,7 +190,7 @@ export class PlatformAdminService { ): Promise { const adminRepo = this.connection.getRepository(ctx, Administrator); const admin = await adminRepo.findOne({ - where: { id: parseInt(administratorId, 10) }, + where: { id: parseInt(administratorId, 10), deletedAt: IsNull() }, relations: ['user', 'user.roles', 'user.roles.channels'], }); if (!admin?.user) return null; diff --git a/backend/src/services/auth/channel-user.service.ts b/backend/src/services/auth/channel-user.service.ts index 8bae1461..3f6adcdf 100644 --- a/backend/src/services/auth/channel-user.service.ts +++ b/backend/src/services/auth/channel-user.service.ts @@ -46,6 +46,7 @@ export class ChannelUserService { .innerJoin('user.roles', 'role') .innerJoin('role.channels', 'channel') .where('channel.id = :channelId', { channelId }) + .andWhere('administrator.deletedAt IS NULL') .andWhere('user.deletedAt IS NULL') .getMany(); @@ -63,6 +64,7 @@ export class ChannelUserService { .innerJoin('user.roles', 'role') .leftJoin('role.channels', 'channel') .where('channel.id IS NULL') + .andWhere('administrator.deletedAt IS NULL') .andWhere('user.deletedAt IS NULL') .getMany(); @@ -100,6 +102,7 @@ export class ChannelUserService { .innerJoinAndSelect('user.roles', 'role') .innerJoin('role.channels', 'channel') .where('channel.id = :channelId', { channelId }) + .andWhere('administrator.deletedAt IS NULL') .andWhere('user.deletedAt IS NULL') .getMany(); @@ -122,6 +125,7 @@ export class ChannelUserService { .innerJoinAndSelect('user.roles', 'role') .leftJoin('role.channels', 'channel') .where('channel.id IS NULL') + .andWhere('administrator.deletedAt IS NULL') .andWhere('user.deletedAt IS NULL') .getMany(); diff --git a/backend/src/services/auth/phone-auth.service.ts b/backend/src/services/auth/phone-auth.service.ts index 9996021f..c8ed79a5 100644 --- a/backend/src/services/auth/phone-auth.service.ts +++ b/backend/src/services/auth/phone-auth.service.ts @@ -191,7 +191,7 @@ export class PhoneAuthService { // Pass context and channelId for tracking if available // Check if user has an associated administrator to get email const administrator = await this.connection.getRepository(ctx, Administrator).findOne({ - where: { user: { id: existingUser.id } }, + where: { user: { id: existingUser.id }, deletedAt: IsNull() }, }); // Pass context and channelId for tracking if available @@ -668,7 +668,7 @@ export class PhoneAuthService { ): Promise { const adminRepo = this.connection.getRepository(ctx, Administrator); const administrator = await adminRepo.findOne({ - where: { user: { id: ctx.activeUserId } }, + where: { user: { id: ctx.activeUserId }, deletedAt: IsNull() }, relations: ['user', 'customFields.profilePicture'], }); diff --git a/backend/src/services/auth/provisioning/access-provisioner.service.ts b/backend/src/services/auth/provisioning/access-provisioner.service.ts index a4492e97..97979abc 100644 --- a/backend/src/services/auth/provisioning/access-provisioner.service.ts +++ b/backend/src/services/auth/provisioning/access-provisioner.service.ts @@ -192,6 +192,22 @@ export class AccessProvisionerService { relations: ['user'], }); + // Update existing Administrator if needed (includes reactivating soft-deleted admins). + let requiresUpdate = false; + + if (administrator && administrator.deletedAt) { + administrator.deletedAt = null as any; + requiresUpdate = true; + } + + // A previously disabled admin also has its User soft-deleted. Restore the User + // so the account can authenticate again. + if (administrator && user.deletedAt) { + user.deletedAt = null as any; + await this.connection.getRepository(ctx, User).save(user); + requiresUpdate = true; + } + if (!administrator) { // Create new Administrator const newAdmin = new Administrator({ @@ -208,9 +224,6 @@ export class AccessProvisionerService { return { administrator, created: true }; } - - // Update existing Administrator if needed - let requiresUpdate = false; if (administrator.firstName !== registrationData.adminFirstName) { administrator.firstName = registrationData.adminFirstName; requiresUpdate = true; diff --git a/backend/src/services/batch-messaging/batch-messaging.service.ts b/backend/src/services/batch-messaging/batch-messaging.service.ts index 72bc6216..9f41d92a 100644 --- a/backend/src/services/batch-messaging/batch-messaging.service.ts +++ b/backend/src/services/batch-messaging/batch-messaging.service.ts @@ -319,7 +319,8 @@ export class BatchMessagingService implements OnModuleInit { .innerJoinAndSelect('admin.user', 'user') .leftJoinAndSelect('user.roles', 'role') .leftJoinAndSelect('role.channels', 'channel') - .where('user.deletedAt IS NULL') + .where('admin.deletedAt IS NULL') + .andWhere('user.deletedAt IS NULL') .getMany(); return this.toRecipients(admins, { defaultShopContext: true }); @@ -333,6 +334,7 @@ export class BatchMessagingService implements OnModuleInit { .innerJoinAndSelect('user.roles', 'role') .leftJoin('role.channels', 'channel') .where('channel.id IS NULL') + .andWhere('admin.deletedAt IS NULL') .andWhere('user.deletedAt IS NULL') .getMany(); @@ -347,7 +349,8 @@ export class BatchMessagingService implements OnModuleInit { .innerJoinAndSelect('admin.user', 'user') .innerJoin('user.roles', 'role') .innerJoin('role.channels', 'channel') - .where('user.deletedAt IS NULL') + .where('admin.deletedAt IS NULL') + .andWhere('user.deletedAt IS NULL') .getMany(); return this.toRecipients(admins); } @@ -359,6 +362,7 @@ export class BatchMessagingService implements OnModuleInit { .innerJoin('user.roles', 'role') .innerJoin('role.channels', 'channel') .where('channel.id IN (:...channelIds)', { channelIds }) + .andWhere('admin.deletedAt IS NULL') .andWhere('user.deletedAt IS NULL') .getMany(); @@ -372,7 +376,8 @@ export class BatchMessagingService implements OnModuleInit { .innerJoinAndSelect('admin.user', 'user') .innerJoinAndSelect('user.roles', 'role') .innerJoin('role.channels', 'channel') - .where('user.deletedAt IS NULL'); + .where('admin.deletedAt IS NULL') + .andWhere('user.deletedAt IS NULL'); if (channelIds.length > 0) { query.andWhere('channel.id IN (:...channelIds)', { channelIds }); @@ -399,7 +404,8 @@ export class BatchMessagingService implements OnModuleInit { .getRepository(Administrator) .createQueryBuilder('admin') .innerJoinAndSelect('admin.user', 'user') - .where('user.id IN (:...adminUserIds)', { adminUserIds }) + .where('admin.deletedAt IS NULL') + .andWhere('user.id IN (:...adminUserIds)', { adminUserIds }) .getMany(); const adminByUserId = new Map(admins.map(a => [a.user?.id.toString(), a])); diff --git a/backend/src/services/channels/channel-admin.service.ts b/backend/src/services/channels/channel-admin.service.ts index 4ba4c317..35c9fc9e 100644 --- a/backend/src/services/channels/channel-admin.service.ts +++ b/backend/src/services/channels/channel-admin.service.ts @@ -13,10 +13,13 @@ import { Role, RoleEvent, RoleService, + SessionService, TransactionalConnection, User, } from '@vendure/core'; +import { CUSTOMER_ROLE_CODE } from '@vendure/common/lib/shared-constants'; import crypto from 'crypto'; +import { IsNull } from 'typeorm'; import { RoleTemplateAssignment } from '../../domain/role-template/role-template-assignment.entity'; import { RoleTemplate } from '../../domain/role-template/role-template.entity'; import { AuditService } from '../../infrastructure/audit/audit.service'; @@ -62,7 +65,8 @@ export class ChannelAdminService { private readonly roleTemplateService: RoleTemplateService, private readonly passwordCipher: PasswordCipher, private readonly eventBus: EventBus, - private readonly entitlementService: EntitlementService + private readonly entitlementService: EntitlementService, + private readonly sessionService: SessionService ) {} /** @@ -117,11 +121,17 @@ export class ChannelAdminService { const existingUser = await this.findExistingUserByPhone(ctx, cleanInput.phoneNumber); if (existingUser) { - // User exists - check if they already belong to this channel + // User exists - check if they already belong to this channel as an active admin. if (this.userBelongsToChannel(existingUser, channelId)) { - throw new BadRequestException( - `Administrator with phone number ${cleanInput.phoneNumber} already belongs to this channel` - ); + const activeAdmin = await this.findActiveAdministratorForUser(ctx, existingUser.id); + if (activeAdmin) { + throw new BadRequestException( + `Administrator with phone number ${cleanInput.phoneNumber} already belongs to this channel` + ); + } + // Stale channel role left behind by the old hard-delete path: remove it + // before re-adding, otherwise the re-add will fail or attach a duplicate role. + await this.removeChannelRolesFromUser(ctx, existingUser.id, channelId); } await this.checkAdminCountLimit(ctx); @@ -287,16 +297,11 @@ export class ChannelAdminService { relations: ['roles', 'roles.channels'], }); - if ( - !user || - !user.roles.some(role => role.channels?.some(ch => this.channelIdMatches(ch.id, channelId))) - ) { + if (!user || !user.roles.some(role => this.isChannelAdminRole(role, channelId))) { throw new BadRequestException('Administrator does not belong to this channel'); } - const role = user.roles.find(r => - r.channels?.some(ch => this.channelIdMatches(ch.id, channelId)) - ); + const role = user.roles.find(r => this.isChannelAdminRole(r, channelId)); if (!role) { throw new BadRequestException('Role not found for administrator'); } @@ -329,13 +334,19 @@ export class ChannelAdminService { } /** - * Disable (soft delete) channel administrator + * Disable a channel administrator for the current channel. + * - Verifies the administrator belongs to the current channel (no cross-tenant disables). + * - Removes only this channel's admin roles, preserving access to other channels. + * The customer role (__customer_role__) is never treated as admin access and is + * never stripped: dual-role users (admin + customer) keep their customer access. + * - Soft-deletes the Administrator row only when the user has no remaining channel admin roles. + * - Invalidates the user's active sessions so revoked permissions take effect immediately. */ async disableChannelAdministrator( ctx: RequestContext, adminId: string ): Promise<{ success: boolean; message: string }> { - this.requireChannelId(ctx); + const channelId = this.requireChannelId(ctx); const administrator = await this.administratorService.findOne(ctx, adminId); if (!administrator) { @@ -349,15 +360,51 @@ export class ChannelAdminService { const user = await this.connection.getRepository(ctx, User).findOne({ where: { id: administrator.user.id }, - relations: ['roles'], + relations: ['roles', 'roles.channels'], }); - if (user && user.roles.length > 0) { - user.roles = []; - await this.connection.getRepository(ctx, User).save(user); + if (!user) { + throw new NotFoundException(`User for administrator ${adminId} not found`); } - await this.connection.getRepository(ctx, Administrator).remove(administrator); + // Security: the administrator must actually belong to the channel being modified. + const belongsToChannel = user.roles.some(role => this.isChannelAdminRole(role, channelId)); + if (!belongsToChannel) { + throw new BadRequestException( + `Administrator with ID ${adminId} does not belong to this channel` + ); + } + + // Remove only the admin roles scoped to this channel. + const rolesToRemove = user.roles.filter(role => this.isChannelAdminRole(role, channelId)); + if (rolesToRemove.length > 0) { + await this.connection + .getRepository(ctx, User) + .createQueryBuilder() + .relation(User, 'roles') + .of(user.id) + .remove(rolesToRemove.map(role => role.id)); + } + + // Determine whether the user still has admin access anywhere else. + // A channel admin role is identified by having at least one channel assigned. + const remainingChannelAdminRoles = user.roles.filter( + role => + !rolesToRemove.some(r => r.id === role.id) && + this.isAdminRole(role) && + (role.channels?.length ?? 0) > 0 + ); + + if (remainingChannelAdminRoles.length === 0) { + // No remaining channel admin access: soft-delete the Administrator row. + administrator.deletedAt = new Date(); + await this.connection.getRepository(ctx, Administrator).save(administrator); + } + + // Invalidate active sessions so the role change is effective immediately. + // SessionService also evicts the session cache; a raw repository delete would + // leave revoked permissions alive until cache expiry. + await this.sessionService.deleteSessionsByUser(ctx, user); await this.auditService .log(ctx, 'admin.disabled', { @@ -366,6 +413,8 @@ export class ChannelAdminService { data: { firstName: administrator.firstName, lastName: administrator.lastName, + channelId: channelId.toString(), + softDeleted: remainingChannelAdminRoles.length === 0, }, }) .catch(err => { @@ -376,7 +425,10 @@ export class ChannelAdminService { return { success: true, - message: 'Administrator disabled successfully', + message: + remainingChannelAdminRoles.length === 0 + ? 'Administrator disabled successfully' + : 'Administrator removed from this channel', }; } @@ -395,7 +447,9 @@ export class ChannelAdminService { return false; } - return user.roles.some(role => !role.channels || role.channels.length === 0); + return user.roles.some( + role => role.code !== CUSTOMER_ROLE_CODE && (!role.channels || role.channels.length === 0) + ); } catch (error) { this.logger.warn( `Failed to check if administrator is superadmin: ${error instanceof Error ? error.message : String(error)}` @@ -597,12 +651,71 @@ export class ChannelAdminService { } } + private async findActiveAdministratorForUser( + ctx: RequestContext, + userId: number | string + ): Promise { + try { + return await this.connection.getRepository(ctx, Administrator).findOne({ + where: { user: { id: userId as any }, deletedAt: IsNull() }, + }); + } catch (error) { + this.logger.warn( + `Failed to find active administrator for user: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } + } + + private async removeChannelRolesFromUser( + ctx: RequestContext, + userId: number | string, + channelId: string | number + ): Promise { + try { + const user = await this.connection.getRepository(ctx, User).findOne({ + where: { id: userId as any }, + relations: ['roles', 'roles.channels'], + }); + if (!user || user.roles.length === 0) return; + + const rolesToRemove = user.roles.filter(role => this.isChannelAdminRole(role, channelId)); + if (rolesToRemove.length === 0) return; + + await this.connection + .getRepository(ctx, User) + .createQueryBuilder() + .relation(User, 'roles') + .of(userId) + .remove(rolesToRemove.map(role => role.id)); + } catch (error) { + this.logger.warn( + `Failed to remove channel roles from user: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + private userBelongsToChannel(user: User, channelId: string | number): boolean { if (!user.roles) { return false; } - return user.roles.some(role => - role.channels?.some(channel => this.channelIdMatches(channel.id, channelId)) + return user.roles.some(role => this.isChannelAdminRole(role, channelId)); + } + + /** + * True when the role grants admin access (i.e. it is not the customer role). + * Dual-role users (admin + customer) carry __customer_role__, which must never + * count as admin access nor be stripped by admin lifecycle operations. + */ + private isAdminRole(role: Role): boolean { + return role.code !== CUSTOMER_ROLE_CODE; + } + + /** True when the role grants admin access scoped to the given channel. */ + private isChannelAdminRole(role: Role, channelId: string | number): boolean { + return ( + this.isAdminRole(role) && + !!role.channels?.some(channel => this.channelIdMatches(channel.id, channelId)) ); } @@ -638,18 +751,34 @@ export class ChannelAdminService { } /** - * Find an existing Administrator for the user, or create one (e.g. when re-adding a user - * who was previously disabled and had their Administrator entity removed). + * Find an existing Administrator for the user, reactivate it if soft-deleted, + * or create one when re-adding a previously disabled channel admin. + * Also restores User.deletedAt so the account can authenticate again. */ private async findOrCreateAdministratorForUser( ctx: RequestContext, existingUser: User, cleanInput: { firstName: string; lastName: string; phoneNumber: string; emailAddress?: string } ): Promise { - const existing = await this.connection.getRepository(ctx, Administrator).findOne({ + const adminRepo = this.connection.getRepository(ctx, Administrator); + const existing = await adminRepo.findOne({ where: { user: { id: existingUser.id } }, }); if (existing) { + let reactivated = false; + if (existing.deletedAt) { + existing.deletedAt = null as any; + await adminRepo.save(existing); + reactivated = true; + } + if (existingUser.deletedAt) { + existingUser.deletedAt = null as any; + await this.connection.getRepository(ctx, User).save(existingUser); + reactivated = true; + } + if (reactivated) { + await this.eventBus.publish(new AdministratorEvent(ctx, existing, 'updated')); + } return existing; } const emailToUse = @@ -662,7 +791,7 @@ export class ChannelAdminService { lastName: cleanInput.lastName, user: existingUser, }); - const savedAdmin = await this.connection.getRepository(ctx, Administrator).save(administrator); + const savedAdmin = await adminRepo.save(administrator); await this.eventBus.publish(new AdministratorEvent(ctx, savedAdmin, 'created')); return savedAdmin; } diff --git a/backend/src/services/customers/customer-creation.service.ts b/backend/src/services/customers/customer-creation.service.ts new file mode 100644 index 00000000..36dad5d9 --- /dev/null +++ b/backend/src/services/customers/customer-creation.service.ts @@ -0,0 +1,219 @@ +import { Injectable } from '@nestjs/common'; +import { + Channel, + Customer, + CustomerEvent, + CustomerService, + EventBus, + RequestContext, + RoleService, + TransactionalConnection, + User, +} from '@vendure/core'; +import { HistoryEntryType } from '@vendure/common/lib/generated-types'; +import { IsNull } from 'typeorm'; +import { normalizeEmailAddress } from '@vendure/core/dist/common/utils'; +import { HistoryService } from '@vendure/core/dist/service/services/history.service'; +import { CustomFieldRelationService } from '@vendure/core/dist/service/helpers/custom-field-relation/custom-field-relation.service'; +import { NATIVE_AUTH_STRATEGY_NAME } from '@vendure/core/dist/config/auth/native-authentication-strategy'; + +/** + * Input type for creating a customer. + * Mirrors the fields accepted by the public customer resolver. + */ +export interface CreateCustomerInput { + firstName: string; + lastName: string; + emailAddress: string; + phoneNumber?: string; + title?: string; + customFields?: Record; +} + +/** + * Customer Creation Service + * + * Wraps Vendure's CustomerService.create with Dukarun-specific behavior: + * - Allows a Customer record to be created for an existing User (e.g. an admin) + * instead of always creating a new User. + * - Publishes the same CustomerEvent lifecycle events that Vendure emits, + * so subscribers (cache-sync, webhooks, etc.) stay consistent. + * + * This exists because Vendure's CustomerService.create always creates a new + * User and cannot attach a Customer entity to an existing User. + */ +@Injectable() +export class CustomerCreationService { + constructor( + private readonly customerService: CustomerService, + private readonly roleService: RoleService, + private readonly eventBus: EventBus, + private readonly connection: TransactionalConnection, + private readonly customFieldRelationService: CustomFieldRelationService, + private readonly historyService: HistoryService + ) {} + + /** + * Create a Customer. If an existing User is supplied, the Customer is linked + * to that User; otherwise Vendure's standard creation path is used. + */ + async create( + ctx: RequestContext, + input: CreateCustomerInput, + existingUser?: User + ): Promise { + if (existingUser) { + return this.createForExistingUser(ctx, input, existingUser); + } + + const result = await this.customerService.create(ctx, input); + if ('errorCode' in result) { + throw new Error(result.message || 'Failed to create customer'); + } + return result; + } + + /** + * Create a Customer record linked to an existing User (e.g. an admin). + * Ensures the User also has the customer role and emits the same `created` + * event Vendure would emit for a new Customer. + * + * Mirrors the invariants enforced by Vendure's CustomerService.create: + * - email addresses are normalized before persistence/conflict checks + * - duplicate active customers in the same channel are rejected + * - an existing active customer with the same email belonging to a different + * user is rejected + * - an existing active customer with the same email belonging to the same user + * is brought into the current channel rather than duplicated. + */ + private async createForExistingUser( + ctx: RequestContext, + input: CreateCustomerInput, + user: User + ): Promise { + const normalizedEmail = normalizeEmailAddress(input.emailAddress); + const customerRepo = this.connection.getRepository(ctx, Customer); + + // Same-channel / same-email conflict check (matches CustomerService.create). + const existingCustomerInChannel = await customerRepo + .createQueryBuilder('customer') + .leftJoin('customer.channels', 'channel') + .where('channel.id = :channelId', { channelId: ctx.channelId }) + .andWhere('customer.emailAddress = :emailAddress', { emailAddress: normalizedEmail }) + .andWhere('customer.deletedAt is null') + .getOne(); + + if (existingCustomerInChannel) { + throw new Error('Email address already exists'); + } + + // Existing active customer with the same email in any channel. + const existingCustomer = await customerRepo.findOne({ + relations: ['channels', 'user'], + where: { + emailAddress: normalizedEmail, + deletedAt: IsNull(), + }, + }); + + if (existingCustomer) { + // Belongs to a different user: conflict. + if (existingCustomer.user?.id !== user.id) { + throw new Error('Email address already exists'); + } + + // Belongs to the same user: add this channel and update fields rather than + // creating a duplicate Customer record. + const channels = existingCustomer.channels || []; + if (!channels.some(channel => String(channel.id) === String(ctx.channelId))) { + channels.push(ctx.channel as Channel); + } + + const updated = await customerRepo.save({ + ...existingCustomer, + firstName: input.firstName, + lastName: input.lastName, + emailAddress: normalizedEmail, + phoneNumber: input.phoneNumber, + title: input.title, + customFields: { ...(existingCustomer.customFields || {}), ...(input.customFields || {}) }, + channels, + }); + + await this.customFieldRelationService.updateRelations(ctx, Customer, input, updated); + await this.historyService.createHistoryEntryForCustomer({ + ctx, + customerId: updated.id, + type: HistoryEntryType.CUSTOMER_DETAIL_UPDATED, + data: { input }, + }); + + await this.eventBus.publish( + new CustomerEvent(ctx, updated, 'updated', { + firstName: input.firstName, + lastName: input.lastName, + emailAddress: normalizedEmail, + phoneNumber: input.phoneNumber, + title: input.title, + customFields: input.customFields, + }) + ); + + return updated; + } + + const customerRole = await this.roleService.getCustomerRole(ctx); + const hasCustomerRole = user.roles?.some(role => role.id === customerRole.id); + + if (!hasCustomerRole) { + await this.connection + .getRepository(ctx, User) + .createQueryBuilder() + .relation(User, 'roles') + .of(user.id) + .add(customerRole.id); + } + + const customer = new Customer({ + firstName: input.firstName, + lastName: input.lastName, + emailAddress: normalizedEmail, + phoneNumber: input.phoneNumber, + title: input.title, + user, + channels: ctx.channel ? [ctx.channel as Channel] : [], + customFields: input.customFields || {}, + }); + + const savedCustomer = await customerRepo.save(customer); + + await this.customFieldRelationService.updateRelations(ctx, Customer, input, savedCustomer); + await this.historyService.createHistoryEntryForCustomer({ + ctx, + customerId: savedCustomer.id, + type: HistoryEntryType.CUSTOMER_REGISTERED, + data: { strategy: NATIVE_AUTH_STRATEGY_NAME }, + }); + if (user.verified) { + await this.historyService.createHistoryEntryForCustomer({ + ctx, + customerId: savedCustomer.id, + type: HistoryEntryType.CUSTOMER_VERIFIED, + data: { strategy: NATIVE_AUTH_STRATEGY_NAME }, + }); + } + + await this.eventBus.publish( + new CustomerEvent(ctx, savedCustomer, 'created', { + firstName: input.firstName, + lastName: input.lastName, + emailAddress: normalizedEmail, + phoneNumber: input.phoneNumber, + title: input.title, + customFields: input.customFields, + }) + ); + + return savedCustomer; + } +} diff --git a/backend/src/services/customers/customer-lifecycle.service.ts b/backend/src/services/customers/customer-lifecycle.service.ts new file mode 100644 index 00000000..283e07e1 --- /dev/null +++ b/backend/src/services/customers/customer-lifecycle.service.ts @@ -0,0 +1,174 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + Administrator, + Customer, + CustomerEvent, + CustomerService, + EventBus, + RequestContext, + TransactionalConnection, + UserInputError, +} from '@vendure/core'; +import { CUSTOMER_ROLE_CODE } from '@vendure/common/lib/shared-constants'; +import { DeletionResult, HistoryEntryType } from '@vendure/common/lib/generated-types'; +import { normalizeEmailAddress } from '@vendure/core/dist/common/utils'; +import { HistoryService } from '@vendure/core/dist/service/services/history.service'; +import { CustomFieldRelationService } from '@vendure/core/dist/service/helpers/custom-field-relation/custom-field-relation.service'; +import { patchEntity } from '@vendure/core/dist/service/helpers/utils/patch-entity'; +import { IsNull } from 'typeorm'; + +/** + * Input type for updating a customer. + * Mirrors Vendure's UpdateCustomerInput. + */ +export interface UpdateCustomerInput { + id: string | number; + firstName?: string; + lastName?: string; + emailAddress?: string; + phoneNumber?: string; + title?: string; + customFields?: Record; +} + +/** + * Customer Lifecycle Service + * + * Shared-user-aware update/delete for customers. + * + * A Customer can share its User with an Administrator (see CustomerCreationService). + * Vendure's stock CustomerService assumes the User is customer-owned, which breaks + * for shared users in two ways: + * - CustomerService.update rewrites the User (and native auth) identifier when the + * email changes, destroying the admin's phone-based login. + * - CustomerService.softDelete soft-deletes the User, locking the admin out. + * + * For shared users we therefore update/delete the Customer row only and never + * touch the User. Non-shared customers delegate to Vendure's stock behavior. + */ +@Injectable() +export class CustomerLifecycleService { + private readonly logger = new Logger(CustomerLifecycleService.name); + + constructor( + private readonly customerService: CustomerService, + private readonly eventBus: EventBus, + private readonly connection: TransactionalConnection, + private readonly customFieldRelationService: CustomFieldRelationService, + private readonly historyService: HistoryService + ) {} + + async update(ctx: RequestContext, input: UpdateCustomerInput): Promise { + const customer = await this.loadActiveCustomer(ctx, input.id); + + if (!(await this.hasSharedUser(ctx, customer))) { + const result = await this.customerService.update(ctx, input as any); + if ('errorCode' in result) { + throw new UserInputError( + (result as { message?: string }).message || 'Failed to update customer' + ); + } + return result; + } + + this.logger.log( + `Updating customer ${customer.id} with shared user ${customer.user!.id}; leaving User identifier untouched` + ); + + const updateInput = { ...input }; + if (updateInput.emailAddress) { + const normalizedEmail = normalizeEmailAddress(updateInput.emailAddress); + if (normalizedEmail !== customer.emailAddress) { + await this.assertEmailAvailableInChannel(ctx, customer.id, normalizedEmail); + updateInput.emailAddress = normalizedEmail; + } + } + + const updatedCustomer = patchEntity(customer, updateInput as any); + await this.connection.getRepository(ctx, Customer).save(updatedCustomer, { reload: false }); + await this.customFieldRelationService.updateRelations( + ctx, + Customer, + updateInput, + updatedCustomer + ); + await this.historyService.createHistoryEntryForCustomer({ + ctx, + customerId: updatedCustomer.id, + type: HistoryEntryType.CUSTOMER_DETAIL_UPDATED, + data: { input: updateInput }, + }); + await this.eventBus.publish(new CustomerEvent(ctx, updatedCustomer, 'updated', updateInput)); + + return updatedCustomer; + } + + async delete(ctx: RequestContext, id: string | number): Promise<{ result: DeletionResult }> { + const customer = await this.loadActiveCustomer(ctx, id); + + if (!(await this.hasSharedUser(ctx, customer))) { + return this.customerService.softDelete(ctx, id); + } + + this.logger.log( + `Soft-deleting customer ${customer.id} with shared user ${customer.user!.id}; keeping the User active` + ); + + await this.connection + .getRepository(ctx, Customer) + .update({ id: customer.id }, { deletedAt: new Date() }); + await this.eventBus.publish(new CustomerEvent(ctx, customer, 'deleted', customer.id)); + + return { result: DeletionResult.DELETED }; + } + + /** + * A customer has a shared user when its User is not customer-owned: either it + * carries any role beyond the customer role, or an active Administrator row + * exists for it (covers cases where role loading is constrained by context). + * Such a User must never be modified or soft-deleted by customer lifecycle + * operations. + */ + private async hasSharedUser(ctx: RequestContext, customer: Customer): Promise { + const user = customer.user; + if (!user) return false; + if (user.roles?.some(role => role.code !== CUSTOMER_ROLE_CODE)) { + return true; + } + const activeAdmin = await this.connection.getRepository(ctx, Administrator).findOne({ + where: { user: { id: user.id }, deletedAt: IsNull() }, + }); + return !!activeAdmin; + } + + private async loadActiveCustomer(ctx: RequestContext, id: string | number): Promise { + const customer = await this.connection.getRepository(ctx, Customer).findOne({ + where: { id: id as any, deletedAt: IsNull() }, + relations: ['user', 'user.roles'], + }); + if (!customer) { + throw new NotFoundException(`Customer with ID ${id} not found`); + } + return customer; + } + + /** Same-channel / same-email conflict check (matches CustomerService.update). */ + private async assertEmailAvailableInChannel( + ctx: RequestContext, + customerId: string | number, + emailAddress: string + ): Promise { + const conflict = await this.connection + .getRepository(ctx, Customer) + .createQueryBuilder('customer') + .leftJoin('customer.channels', 'channel') + .where('channel.id = :channelId', { channelId: ctx.channelId }) + .andWhere('customer.emailAddress = :emailAddress', { emailAddress }) + .andWhere('customer.id != :customerId', { customerId }) + .andWhere('customer.deletedAt is null') + .getOne(); + if (conflict) { + throw new UserInputError('Email address already exists'); + } + } +} diff --git a/backend/src/services/provisioning/context-adapter.service.ts b/backend/src/services/provisioning/context-adapter.service.ts index d16f2736..6649aec0 100644 --- a/backend/src/services/provisioning/context-adapter.service.ts +++ b/backend/src/services/provisioning/context-adapter.service.ts @@ -11,6 +11,7 @@ import { } from '@vendure/core'; import { withChannel } from '../../utils/request-context.util'; import { withSellerFromChannel } from '../../utils/seller-access.util'; +import { IsNull } from 'typeorm'; /** * Provisioning Context Adapter @@ -196,7 +197,7 @@ export class ProvisioningContextAdapter { ): Promise { const adminRepo = this.connection.getRepository(ctx, Administrator); const administrator = await adminRepo.findOne({ - where: { id: adminId }, + where: { id: adminId, deletedAt: IsNull() }, relations: ['user'], }); diff --git a/backend/src/vendure-config.ts b/backend/src/vendure-config.ts index 8871eea8..03b70419 100644 --- a/backend/src/vendure-config.ts +++ b/backend/src/vendure-config.ts @@ -25,7 +25,7 @@ import { ChannelEventsPlugin } from './plugins/channels/channel-events.plugin'; import { ChannelSettingsPlugin } from './plugins/channels/channel-settings.plugin'; import { EnvironmentPlugin } from './plugins/core/environment.plugin'; import { CreditPlugin } from './plugins/credit/credit.plugin'; -import { CustomerPlugin } from './plugins/customers/customer.plugin'; +import { CustomerPlugin, CustomerAdminPlugin } from './plugins/customers/customer.plugin'; import { ApproveCustomerCreditPermission, ManageCustomerCreditLimitPermission, @@ -266,6 +266,7 @@ export const config: VendureConfig = { StockPlugin, // Load before CreditPlugin so StockPurchase type is available CreditPlugin, // Depends on LedgerPlugin CustomerPlugin, // Customer duplicate prevention + CustomerAdminPlugin, // Admin-only shared-user-safe customer update/delete SubscriptionPlugin, StorefrontPlugin, // Public per-merchant storefront: shop-api storefront(slug) + sitemap/robots ChannelEventsPlugin, diff --git a/frontend/src/app/domains/customer/operations.graphql.ts b/frontend/src/app/domains/customer/operations.graphql.ts index d2afc709..f303f693 100644 --- a/frontend/src/app/domains/customer/operations.graphql.ts +++ b/frontend/src/app/domains/customer/operations.graphql.ts @@ -132,29 +132,23 @@ export const CREATE_CUSTOMER = graphql(` export const UPDATE_CUSTOMER = graphql(` mutation UpdateCustomer($input: UpdateCustomerInput!) { - updateCustomer(input: $input) { - ... on Customer { - id - firstName - lastName - emailAddress - phoneNumber - updatedAt - customFields { - isSupplier - supplierType - contactPerson - taxId - paymentTerms - notes - isCreditApproved - creditLimit - notificationsEnabled - } - } - ... on EmailAddressConflictError { - errorCode - message + updateCustomerSafe(input: $input) { + id + firstName + lastName + emailAddress + phoneNumber + updatedAt + customFields { + isSupplier + supplierType + contactPerson + taxId + paymentTerms + notes + isCreditApproved + creditLimit + notificationsEnabled } } } @@ -162,7 +156,7 @@ export const UPDATE_CUSTOMER = graphql(` export const DELETE_CUSTOMER = graphql(` mutation DeleteCustomer($id: ID!) { - deleteCustomer(id: $id) { + deleteCustomerSafe(id: $id) { result message } diff --git a/frontend/src/app/domains/customer/services/customer-api.service.ts b/frontend/src/app/domains/customer/services/customer-api.service.ts index 410b2a65..c150d12e 100644 --- a/frontend/src/app/domains/customer/services/customer-api.service.ts +++ b/frontend/src/app/domains/customer/services/customer-api.service.ts @@ -108,14 +108,11 @@ export class CustomerApiService { variables: { input: updateInput }, }); - const updated = updateResult.data?.updateCustomer; - if (updated?.__typename === 'Customer') { + const updated = updateResult.data?.updateCustomerSafe; + if (updated?.id) { console.log('✅ Customer fields updated:', updated.id); this.stateService.setIsCreating(false); return updated.id; - } else if (updated?.__typename === 'EmailAddressConflictError') { - this.stateService.setError(updated.message || 'Failed to update customer'); - return null; } else { this.stateService.setError('Failed to update customer'); return null; @@ -297,17 +294,13 @@ export class CustomerApiService { variables: { input: mutationInput }, }); - const customer = result.data?.updateCustomer; - if (customer?.__typename === 'Customer') { + const customer = result.data?.updateCustomerSafe; + if (customer?.id) { console.log('✅ Customer updated:', customer.id); return true; - } else if (customer?.__typename === 'EmailAddressConflictError') { - this.stateService.setError(customer.message || 'Failed to update customer'); - return false; - } else { - this.stateService.setError('Failed to update customer'); - return false; } + this.stateService.setError('Failed to update customer'); + return false; } catch (error: any) { console.error('❌ Customer update failed:', error); this.stateService.setError(error.message || 'Failed to update customer'); @@ -330,7 +323,7 @@ export class CustomerApiService { variables: { id: customerId }, }); - const deleteResult = result.data?.deleteCustomer; + const deleteResult = result.data?.deleteCustomerSafe; if (deleteResult?.result === 'DELETED') { console.log('✅ Customer deleted successfully'); diff --git a/frontend/src/app/domains/supplier/services/supplier-api.service.ts b/frontend/src/app/domains/supplier/services/supplier-api.service.ts index 682f1737..37c1d89c 100644 --- a/frontend/src/app/domains/supplier/services/supplier-api.service.ts +++ b/frontend/src/app/domains/supplier/services/supplier-api.service.ts @@ -113,17 +113,14 @@ export class SupplierApiService { variables: { input: updateInput }, }); - const updated = updateResult.data?.updateCustomer; - if (updated?.__typename === 'Customer') { + const updated = updateResult.data?.updateCustomerSafe; + if (updated?.id) { console.log( `✅ ${isAlreadySupplier ? 'Supplier fields updated' : 'Supplier capability added'}:`, updated.id, ); this.stateService.setIsCreating(false); return updated.id; - } else if (updated?.__typename === 'EmailAddressConflictError') { - this.stateService.setError(updated.message || 'Failed to add supplier capability'); - return null; } else { this.stateService.setError('Failed to add supplier capability'); return null; diff --git a/frontend/src/app/shared/graphql/generated/gql.ts b/frontend/src/app/shared/graphql/generated/gql.ts index 5f94ddd9..66c70819 100644 --- a/frontend/src/app/shared/graphql/generated/gql.ts +++ b/frontend/src/app/shared/graphql/generated/gql.ts @@ -106,8 +106,8 @@ type Documents = { '\n query GetCustomers($options: CustomerListOptions) {\n customers(options: $options) {\n totalItems\n items {\n id\n firstName\n lastName\n emailAddress\n phoneNumber\n createdAt\n updatedAt\n outstandingAmount\n daysOverdue\n isOverdue\n supplierOutstandingAmount\n supplierDaysOverdue\n supplierIsOverdue\n customFields {\n isSupplier\n supplierType\n contactPerson\n taxId\n paymentTerms\n notes\n isCreditApproved\n creditLimit\n lastRepaymentDate\n lastRepaymentAmount\n creditDuration\n notificationsEnabled\n }\n addresses {\n id\n fullName\n streetLine1\n streetLine2\n city\n postalCode\n country {\n code\n name\n }\n phoneNumber\n }\n user {\n id\n identifier\n verified\n }\n }\n }\n }\n': typeof types.GetCustomersDocument; '\n query GetCustomer($id: ID!) {\n customer(id: $id) {\n id\n firstName\n lastName\n emailAddress\n phoneNumber\n createdAt\n updatedAt\n outstandingAmount\n daysOverdue\n isOverdue\n supplierOutstandingAmount\n supplierDaysOverdue\n supplierIsOverdue\n customFields {\n isSupplier\n supplierType\n contactPerson\n taxId\n paymentTerms\n notes\n isCreditApproved\n creditLimit\n lastRepaymentDate\n lastRepaymentAmount\n creditDuration\n notificationsEnabled\n }\n addresses {\n id\n fullName\n streetLine1\n streetLine2\n city\n postalCode\n country {\n code\n name\n }\n phoneNumber\n }\n user {\n id\n identifier\n verified\n }\n }\n }\n': typeof types.GetCustomerDocument; '\n mutation CreateCustomer($input: CreateCustomerInput!, $isWalkIn: Boolean) {\n createCustomerSafe(input: $input, isWalkIn: $isWalkIn) {\n id\n firstName\n lastName\n emailAddress\n phoneNumber\n createdAt\n customFields {\n isSupplier\n supplierType\n contactPerson\n taxId\n paymentTerms\n notes\n isCreditApproved\n creditLimit\n }\n }\n }\n': typeof types.CreateCustomerDocument; - '\n mutation UpdateCustomer($input: UpdateCustomerInput!) {\n updateCustomer(input: $input) {\n ... on Customer {\n id\n firstName\n lastName\n emailAddress\n phoneNumber\n updatedAt\n customFields {\n isSupplier\n supplierType\n contactPerson\n taxId\n paymentTerms\n notes\n isCreditApproved\n creditLimit\n notificationsEnabled\n }\n }\n ... on EmailAddressConflictError {\n errorCode\n message\n }\n }\n }\n': typeof types.UpdateCustomerDocument; - '\n mutation DeleteCustomer($id: ID!) {\n deleteCustomer(id: $id) {\n result\n message\n }\n }\n': typeof types.DeleteCustomerDocument; + '\n mutation UpdateCustomer($input: UpdateCustomerInput!) {\n updateCustomerSafe(input: $input) {\n id\n firstName\n lastName\n emailAddress\n phoneNumber\n updatedAt\n customFields {\n isSupplier\n supplierType\n contactPerson\n taxId\n paymentTerms\n notes\n isCreditApproved\n creditLimit\n notificationsEnabled\n }\n }\n }\n': typeof types.UpdateCustomerDocument; + '\n mutation DeleteCustomer($id: ID!) {\n deleteCustomerSafe(id: $id) {\n result\n message\n }\n }\n': typeof types.DeleteCustomerDocument; '\n mutation CreateCustomerAddress($customerId: ID!, $input: CreateAddressInput!) {\n createCustomerAddress(customerId: $customerId, input: $input) {\n id\n fullName\n streetLine1\n streetLine2\n city\n postalCode\n country {\n code\n name\n }\n phoneNumber\n }\n }\n': typeof types.CreateCustomerAddressDocument; '\n mutation UpdateCustomerAddress($input: UpdateAddressInput!) {\n updateCustomerAddress(input: $input) {\n id\n fullName\n streetLine1\n streetLine2\n city\n postalCode\n country {\n code\n name\n }\n phoneNumber\n }\n }\n': typeof types.UpdateCustomerAddressDocument; '\n mutation DeleteCustomerAddress($id: ID!) {\n deleteCustomerAddress(id: $id) {\n success\n }\n }\n': typeof types.DeleteCustomerAddressDocument; @@ -381,9 +381,9 @@ const documents: Documents = { types.GetCustomerDocument, '\n mutation CreateCustomer($input: CreateCustomerInput!, $isWalkIn: Boolean) {\n createCustomerSafe(input: $input, isWalkIn: $isWalkIn) {\n id\n firstName\n lastName\n emailAddress\n phoneNumber\n createdAt\n customFields {\n isSupplier\n supplierType\n contactPerson\n taxId\n paymentTerms\n notes\n isCreditApproved\n creditLimit\n }\n }\n }\n': types.CreateCustomerDocument, - '\n mutation UpdateCustomer($input: UpdateCustomerInput!) {\n updateCustomer(input: $input) {\n ... on Customer {\n id\n firstName\n lastName\n emailAddress\n phoneNumber\n updatedAt\n customFields {\n isSupplier\n supplierType\n contactPerson\n taxId\n paymentTerms\n notes\n isCreditApproved\n creditLimit\n notificationsEnabled\n }\n }\n ... on EmailAddressConflictError {\n errorCode\n message\n }\n }\n }\n': + '\n mutation UpdateCustomer($input: UpdateCustomerInput!) {\n updateCustomerSafe(input: $input) {\n id\n firstName\n lastName\n emailAddress\n phoneNumber\n updatedAt\n customFields {\n isSupplier\n supplierType\n contactPerson\n taxId\n paymentTerms\n notes\n isCreditApproved\n creditLimit\n notificationsEnabled\n }\n }\n }\n': types.UpdateCustomerDocument, - '\n mutation DeleteCustomer($id: ID!) {\n deleteCustomer(id: $id) {\n result\n message\n }\n }\n': + '\n mutation DeleteCustomer($id: ID!) {\n deleteCustomerSafe(id: $id) {\n result\n message\n }\n }\n': types.DeleteCustomerDocument, '\n mutation CreateCustomerAddress($customerId: ID!, $input: CreateAddressInput!) {\n createCustomerAddress(customerId: $customerId, input: $input) {\n id\n fullName\n streetLine1\n streetLine2\n city\n postalCode\n country {\n code\n name\n }\n phoneNumber\n }\n }\n': types.CreateCustomerAddressDocument, @@ -1132,14 +1132,14 @@ export function graphql( * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql( - source: '\n mutation UpdateCustomer($input: UpdateCustomerInput!) {\n updateCustomer(input: $input) {\n ... on Customer {\n id\n firstName\n lastName\n emailAddress\n phoneNumber\n updatedAt\n customFields {\n isSupplier\n supplierType\n contactPerson\n taxId\n paymentTerms\n notes\n isCreditApproved\n creditLimit\n notificationsEnabled\n }\n }\n ... on EmailAddressConflictError {\n errorCode\n message\n }\n }\n }\n', -): (typeof documents)['\n mutation UpdateCustomer($input: UpdateCustomerInput!) {\n updateCustomer(input: $input) {\n ... on Customer {\n id\n firstName\n lastName\n emailAddress\n phoneNumber\n updatedAt\n customFields {\n isSupplier\n supplierType\n contactPerson\n taxId\n paymentTerms\n notes\n isCreditApproved\n creditLimit\n notificationsEnabled\n }\n }\n ... on EmailAddressConflictError {\n errorCode\n message\n }\n }\n }\n']; + source: '\n mutation UpdateCustomer($input: UpdateCustomerInput!) {\n updateCustomerSafe(input: $input) {\n id\n firstName\n lastName\n emailAddress\n phoneNumber\n updatedAt\n customFields {\n isSupplier\n supplierType\n contactPerson\n taxId\n paymentTerms\n notes\n isCreditApproved\n creditLimit\n notificationsEnabled\n }\n }\n }\n', +): (typeof documents)['\n mutation UpdateCustomer($input: UpdateCustomerInput!) {\n updateCustomerSafe(input: $input) {\n id\n firstName\n lastName\n emailAddress\n phoneNumber\n updatedAt\n customFields {\n isSupplier\n supplierType\n contactPerson\n taxId\n paymentTerms\n notes\n isCreditApproved\n creditLimit\n notificationsEnabled\n }\n }\n }\n']; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql( - source: '\n mutation DeleteCustomer($id: ID!) {\n deleteCustomer(id: $id) {\n result\n message\n }\n }\n', -): (typeof documents)['\n mutation DeleteCustomer($id: ID!) {\n deleteCustomer(id: $id) {\n result\n message\n }\n }\n']; + source: '\n mutation DeleteCustomer($id: ID!) {\n deleteCustomerSafe(id: $id) {\n result\n message\n }\n }\n', +): (typeof documents)['\n mutation DeleteCustomer($id: ID!) {\n deleteCustomerSafe(id: $id) {\n result\n message\n }\n }\n']; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/src/app/shared/graphql/generated/graphql.ts b/frontend/src/app/shared/graphql/generated/graphql.ts index 07bcd1df..56762c9b 100644 --- a/frontend/src/app/shared/graphql/generated/graphql.ts +++ b/frontend/src/app/shared/graphql/generated/graphql.ts @@ -4136,6 +4136,8 @@ export type Mutation = { /** Delete multiple CustomerGroups */ deleteCustomerGroups: Array; deleteCustomerNote: DeletionResponse; + /** Delete a customer without soft-deleting the shared user. */ + deleteCustomerSafe: DeletionResponse; /** Deletes Customers */ deleteCustomers: Array; /** Deletes a draft Order */ @@ -4380,6 +4382,8 @@ export type Mutation = { updateCustomerGroup: CustomerGroup; updateCustomerNote: HistoryEntry; updateCustomerNotificationsEnabled: PlatformSettings; + /** Update a customer without rewriting the login identifier of a shared user. */ + updateCustomerSafe: Customer; updateDraftPurchase: StockPurchase; /** Update an existing Facet */ updateFacet: Facet; @@ -4819,6 +4823,10 @@ export type MutationDeleteCustomerNoteArgs = { id: Scalars['ID']['input']; }; +export type MutationDeleteCustomerSafeArgs = { + id: Scalars['ID']['input']; +}; + export type MutationDeleteCustomersArgs = { ids: Array; }; @@ -5443,6 +5451,10 @@ export type MutationUpdateCustomerNotificationsEnabledArgs = { enabled: Scalars['Boolean']['input']; }; +export type MutationUpdateCustomerSafeArgs = { + input: UpdateCustomerInput; +}; + export type MutationUpdateDraftPurchaseArgs = { id: Scalars['ID']['input']; input: UpdateDraftPurchaseInput; @@ -12338,29 +12350,27 @@ export type UpdateCustomerMutationVariables = Exact<{ export type UpdateCustomerMutation = { __typename?: 'Mutation'; - updateCustomer: - | { - __typename?: 'Customer'; - id: string; - firstName: string; - lastName: string; - emailAddress: string; - phoneNumber?: string | null; - updatedAt: any; - customFields?: { - __typename?: 'CustomerCustomFields'; - isSupplier?: boolean | null; - supplierType?: string | null; - contactPerson?: string | null; - taxId?: string | null; - paymentTerms?: string | null; - notes?: string | null; - isCreditApproved?: boolean | null; - creditLimit?: number | null; - notificationsEnabled?: boolean | null; - } | null; - } - | { __typename?: 'EmailAddressConflictError'; errorCode: ErrorCode; message: string }; + updateCustomerSafe: { + __typename?: 'Customer'; + id: string; + firstName: string; + lastName: string; + emailAddress: string; + phoneNumber?: string | null; + updatedAt: any; + customFields?: { + __typename?: 'CustomerCustomFields'; + isSupplier?: boolean | null; + supplierType?: string | null; + contactPerson?: string | null; + taxId?: string | null; + paymentTerms?: string | null; + notes?: string | null; + isCreditApproved?: boolean | null; + creditLimit?: number | null; + notificationsEnabled?: boolean | null; + } | null; + }; }; export type DeleteCustomerMutationVariables = Exact<{ @@ -12369,7 +12379,7 @@ export type DeleteCustomerMutationVariables = Exact<{ export type DeleteCustomerMutation = { __typename?: 'Mutation'; - deleteCustomer: { + deleteCustomerSafe: { __typename?: 'DeletionResponse'; result: DeletionResult; message?: string | null; @@ -20461,7 +20471,7 @@ export const UpdateCustomerDocument = { selections: [ { kind: 'Field', - name: { kind: 'Name', value: 'updateCustomer' }, + name: { kind: 'Name', value: 'updateCustomerSafe' }, arguments: [ { kind: 'Argument', @@ -20472,53 +20482,27 @@ export const UpdateCustomerDocument = { selectionSet: { kind: 'SelectionSet', selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'firstName' } }, + { kind: 'Field', name: { kind: 'Name', value: 'lastName' } }, + { kind: 'Field', name: { kind: 'Name', value: 'emailAddress' } }, + { kind: 'Field', name: { kind: 'Name', value: 'phoneNumber' } }, + { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } }, { - kind: 'InlineFragment', - typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'Customer' } }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, - { kind: 'Field', name: { kind: 'Name', value: 'firstName' } }, - { kind: 'Field', name: { kind: 'Name', value: 'lastName' } }, - { kind: 'Field', name: { kind: 'Name', value: 'emailAddress' } }, - { kind: 'Field', name: { kind: 'Name', value: 'phoneNumber' } }, - { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } }, - { - kind: 'Field', - name: { kind: 'Name', value: 'customFields' }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'isSupplier' } }, - { kind: 'Field', name: { kind: 'Name', value: 'supplierType' } }, - { kind: 'Field', name: { kind: 'Name', value: 'contactPerson' } }, - { kind: 'Field', name: { kind: 'Name', value: 'taxId' } }, - { kind: 'Field', name: { kind: 'Name', value: 'paymentTerms' } }, - { kind: 'Field', name: { kind: 'Name', value: 'notes' } }, - { kind: 'Field', name: { kind: 'Name', value: 'isCreditApproved' } }, - { kind: 'Field', name: { kind: 'Name', value: 'creditLimit' } }, - { - kind: 'Field', - name: { kind: 'Name', value: 'notificationsEnabled' }, - }, - ], - }, - }, - ], - }, - }, - { - kind: 'InlineFragment', - typeCondition: { - kind: 'NamedType', - name: { kind: 'Name', value: 'EmailAddressConflictError' }, - }, + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, selectionSet: { kind: 'SelectionSet', selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'errorCode' } }, - { kind: 'Field', name: { kind: 'Name', value: 'message' } }, + { kind: 'Field', name: { kind: 'Name', value: 'isSupplier' } }, + { kind: 'Field', name: { kind: 'Name', value: 'supplierType' } }, + { kind: 'Field', name: { kind: 'Name', value: 'contactPerson' } }, + { kind: 'Field', name: { kind: 'Name', value: 'taxId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'paymentTerms' } }, + { kind: 'Field', name: { kind: 'Name', value: 'notes' } }, + { kind: 'Field', name: { kind: 'Name', value: 'isCreditApproved' } }, + { kind: 'Field', name: { kind: 'Name', value: 'creditLimit' } }, + { kind: 'Field', name: { kind: 'Name', value: 'notificationsEnabled' } }, ], }, }, @@ -20552,7 +20536,7 @@ export const DeleteCustomerDocument = { selections: [ { kind: 'Field', - name: { kind: 'Name', value: 'deleteCustomer' }, + name: { kind: 'Name', value: 'deleteCustomerSafe' }, arguments: [ { kind: 'Argument', diff --git a/frontend/src/app/shell/app.config.ts b/frontend/src/app/shell/app.config.ts index dee622fa..3ab0b8ec 100644 --- a/frontend/src/app/shell/app.config.ts +++ b/frontend/src/app/shell/app.config.ts @@ -1,4 +1,4 @@ -import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClient, withFetch } from '@angular/common/http'; import { APP_INITIALIZER, ApplicationConfig, @@ -47,7 +47,7 @@ export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideZonelessChangeDetection(), - provideHttpClient(), + provideHttpClient(withFetch()), // Register the app's icon set once; default size 1rem (16px) matches the // dense UI, overridable per-icon via . provideNgIconsConfig({ size: '1rem' }), diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts index b814b859..f5eacef4 100644 --- a/frontend/src/environments/environment.prod.ts +++ b/frontend/src/environments/environment.prod.ts @@ -29,7 +29,8 @@ export const environment = { apiUrl: '/admin-api', // Will use same origin in production vendureAdminUrl: runtimeConfig.vendureAdminUrl ?? '/admin', // Backend Admin UI (same origin or override) // SigNoz Observability Configuration - injected at runtime via window.__APP_CONFIG__ - enableTracing: runtimeConfig.enableTracing ?? true, + // Disabled by default: the SigNoz stack is not currently provisioned. + enableTracing: runtimeConfig.enableTracing ?? false, signozEndpoint: runtimeConfig.signozEndpoint ?? '/signoz/v1/traces', serviceName: runtimeConfig.serviceName ?? `${BRAND_CONFIG.servicePrefix}-frontend`, serviceVersion: runtimeConfig.serviceVersion ?? '2.0.0',