Phase 1: Foundation
Database setup, Identity Resolution, and Basic API.
Deliverables
- Prisma schema created and migrated
- CustomerProfile CRUD operations
- IdentityResolution service
- Basic REST API for customers
- Unit tests for identity resolution
1. Database Setup
Create Prisma Client Library
cd libs/prisma/crm-client
pnpm prisma init
Schema Setup
Copy the schema from data-model to:
libs/prisma/crm-client/prisma/schema.prisma
Initial Migration
# Set database URL in Infisical: CRM_DATABASE_URL=postgresql://...
cd libs/prisma/crm-client
pnpm prisma migrate dev --name init
pnpm prisma generate
2. Core Service Module
Project Structure
libs/crm/core/
├── src/
│ ├── index.ts
│ ├── crm-core.module.ts
│ ├── services/
│ │ ├── customer-profile.service.ts
│ │ ├── identity-resolution.service.ts
│ │ └── profile-merge.service.ts
│ ├── repositories/
│ │ ├── customer-profile.repository.ts
│ │ └── identity-alias.repository.ts
│ └── dto/
│ ├── create-customer.dto.ts
│ ├── update-customer.dto.ts
│ └── resolve-identity.dto.ts
├── project.json
└── tsconfig.lib.json
Identity Resolution Service
// libs/crm/core/src/services/identity-resolution.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '@digiwedge/crm-client';
export interface IdentityHints {
tenantId: string;
authUserId?: string;
email?: string;
phoneNumber?: string;
sclMemberId?: number;
teetimePlayerId?: string;
messagingContactId?: string;
externalIds?: Record<string, string>;
}
export interface ResolutionResult {
profile: CustomerProfile;
isNew: boolean;
matchedBy: string[];
confidence: number;
}
@Injectable()
export class IdentityResolutionService {
private readonly logger = new Logger(IdentityResolutionService.name);
constructor(private readonly prisma: PrismaService) {}
async resolve(hints: IdentityHints): Promise<ResolutionResult> {
const { tenantId, authUserId, email, phoneNumber } = hints;
// 1. Try authUserId (100% confidence)
if (authUserId) {
const profile = await this.prisma.customerProfile.findUnique({
where: { authUserId },
});
if (profile) {
return { profile, isNew: false, matchedBy: ['authUserId'], confidence: 100 };
}
}
// 2. Try email (95% confidence)
if (email) {
const normalizedEmail = this.normalizeEmail(email);
const profile = await this.prisma.customerProfile.findUnique({
where: { tenantId_email: { tenantId, email: normalizedEmail } },
});
if (profile) {
// Link authUserId if we now have it
if (authUserId && !profile.authUserId) {
await this.prisma.customerProfile.update({
where: { id: profile.id },
data: { authUserId },
});
}
return { profile, isNew: false, matchedBy: ['email'], confidence: 95 };
}
}
// 3. Try phone (85% confidence)
if (phoneNumber) {
const e164Phone = this.normalizePhone(phoneNumber);
const profile = await this.prisma.customerProfile.findUnique({
where: { tenantId_phoneNumber: { tenantId, phoneNumber: e164Phone } },
});
if (profile) {
return { profile, isNew: false, matchedBy: ['phone'], confidence: 85 };
}
}
// 4. Try cross-service links
if (hints.sclMemberId) {
const profile = await this.prisma.customerProfile.findUnique({
where: { tenantId_sclMemberId: { tenantId, sclMemberId: hints.sclMemberId } },
});
if (profile) {
return { profile, isNew: false, matchedBy: ['sclMemberId'], confidence: 100 };
}
}
if (hints.teetimePlayerId) {
const profile = await this.prisma.customerProfile.findUnique({
where: { tenantId_teetimePlayerId: { tenantId, teetimePlayerId: hints.teetimePlayerId } },
});
if (profile) {
return { profile, isNew: false, matchedBy: ['teetimePlayerId'], confidence: 100 };
}
}
// 5. No match - create new profile
const newProfile = await this.prisma.customerProfile.create({
data: {
tenantId,
authUserId,
email: email ? this.normalizeEmail(email) : undefined,
phoneNumber: phoneNumber ? this.normalizePhone(phoneNumber) : undefined,
sclMemberId: hints.sclMemberId,
teetimePlayerId: hints.teetimePlayerId,
messagingContactId: hints.messagingContactId,
externalIds: hints.externalIds || {},
status: 'ACTIVE',
lifecycleStage: 'SUBSCRIBER',
},
});
this.logger.log(`Created new profile ${newProfile.id} for tenant ${tenantId}`);
return { profile: newProfile, isNew: true, matchedBy: [], confidence: 100 };
}
private normalizeEmail(email: string): string {
return email.toLowerCase().trim();
}
private normalizePhone(phone: string): string {
// Remove all non-digits
const digits = phone.replace(/\D/g, '');
// South Africa: +27 format
if (digits.startsWith('27') && digits.length === 11) {
return `+${digits}`;
}
// Local format starting with 0
if (digits.startsWith('0') && digits.length === 10) {
return `+27${digits.slice(1)}`;
}
// Already E.164
if (phone.startsWith('+')) {
return phone;
}
return phone;
}
}
Customer Profile Service
// libs/crm/core/src/services/customer-profile.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '@digiwedge/crm-client';
import { Prisma } from '@prisma/crm';
@Injectable()
export class CustomerProfileService {
constructor(private readonly prisma: PrismaService) {}
async list(query: ListCustomersQuery) {
const { tenantId, status, lifecycleStage, search, segmentId, skip = 0, take = 25 } = query;
const where: Prisma.CustomerProfileWhereInput = {
tenantId,
deletedAt: null,
};
if (status) where.status = status;
if (lifecycleStage) where.lifecycleStage = lifecycleStage;
if (search) {
where.OR = [
{ email: { contains: search, mode: 'insensitive' } },
{ firstName: { contains: search, mode: 'insensitive' } },
{ lastName: { contains: search, mode: 'insensitive' } },
{ phoneNumber: { contains: search } },
];
}
if (segmentId) {
where.segmentMemberships = { some: { segmentId } };
}
const [data, total] = await Promise.all([
this.prisma.customerProfile.findMany({
where,
skip,
take: Math.min(take, 100),
orderBy: { lastActivityAt: 'desc' },
}),
this.prisma.customerProfile.count({ where }),
]);
return {
data,
meta: { total, skip, take, hasMore: skip + data.length < total },
};
}
async getById(id: string) {
const profile = await this.prisma.customerProfile.findUnique({
where: { id },
include: {
segmentMemberships: { include: { segment: true } },
},
});
if (!profile) throw new NotFoundException('Customer not found');
return profile;
}
async update(id: string, data: UpdateCustomerDto, updatedBy: string) {
const profile = await this.prisma.customerProfile.update({
where: { id },
data: {
...data,
updatedBy,
},
});
return profile;
}
}
3. REST API
Customer Controller
// apps/crm/crm-backend/src/app/customer/customer.controller.ts
import { Controller, Get, Post, Patch, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
@ApiTags('customers')
@Controller('api/v1/customers')
export class CustomerController {
constructor(
private readonly customerService: CustomerProfileService,
private readonly identityService: IdentityResolutionService,
) {}
@Get()
@ApiOperation({ summary: 'List customers with filters' })
async list(@Query() query: ListCustomersDto) {
return this.customerService.list(query);
}
@Get('resolve')
@ApiOperation({ summary: 'Resolve customer by identity hints' })
async resolve(@Query() hints: ResolveIdentityDto) {
return this.identityService.resolve(hints);
}
@Get(':id')
@ApiOperation({ summary: 'Get customer by ID' })
async get(@Param('id') id: string) {
return this.customerService.getById(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update customer' })
async update(
@Param('id') id: string,
@Body() dto: UpdateCustomerDto,
@CurrentUser() user: AuthUser,
) {
return this.customerService.update(id, dto, user.id);
}
@Post(':id/merge')
@ApiOperation({ summary: 'Merge two customer profiles' })
async merge(
@Param('id') winnerId: string,
@Body() dto: MergeCustomerDto,
@CurrentUser() user: AuthUser,
) {
return this.customerService.merge(winnerId, dto.loserId, user.id);
}
}
DTOs
// libs/crm/core/src/dto/list-customers.dto.ts
import { IsOptional, IsString, IsEnum, IsNumber, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
export class ListCustomersDto {
@IsString()
tenantId: string;
@IsOptional()
@IsEnum(['ACTIVE', 'INACTIVE', 'MERGED', 'DELETED'])
status?: string;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsString()
segmentId?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
skip?: number = 0;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(100)
take?: number = 25;
}
4. Module Setup
// libs/crm/core/src/crm-core.module.ts
import { Module } from '@nestjs/common';
import { PrismaModule } from '@digiwedge/crm-client';
import { CustomerProfileService } from './services/customer-profile.service';
import { IdentityResolutionService } from './services/identity-resolution.service';
import { ProfileMergeService } from './services/profile-merge.service';
@Module({
imports: [PrismaModule],
providers: [
CustomerProfileService,
IdentityResolutionService,
ProfileMergeService,
],
exports: [
CustomerProfileService,
IdentityResolutionService,
ProfileMergeService,
],
})
export class CrmCoreModule {}
5. Unit Tests
// libs/crm/core/src/services/__tests__/identity-resolution.service.spec.ts
describe('IdentityResolutionService', () => {
let service: IdentityResolutionService;
let prisma: MockPrismaService;
beforeEach(() => {
prisma = createMockPrisma();
service = new IdentityResolutionService(prisma);
});
describe('resolve', () => {
it('should match by authUserId', async () => {
const existingProfile = { id: '123', authUserId: 'auth0|123' };
prisma.customerProfile.findUnique.mockResolvedValue(existingProfile);
const result = await service.resolve({
tenantId: 'tenant_1',
authUserId: 'auth0|123',
});
expect(result.isNew).toBe(false);
expect(result.matchedBy).toContain('authUserId');
expect(result.confidence).toBe(100);
});
it('should match by email when no authUserId', async () => {
prisma.customerProfile.findUnique
.mockResolvedValueOnce(null) // authUserId lookup
.mockResolvedValueOnce({ id: '123', email: 'test@example.com' }); // email lookup
const result = await service.resolve({
tenantId: 'tenant_1',
email: 'TEST@Example.com',
});
expect(result.isNew).toBe(false);
expect(result.matchedBy).toContain('email');
expect(result.confidence).toBe(95);
});
it('should create new profile when no match', async () => {
prisma.customerProfile.findUnique.mockResolvedValue(null);
prisma.customerProfile.create.mockResolvedValue({
id: 'new-123',
email: 'new@example.com',
});
const result = await service.resolve({
tenantId: 'tenant_1',
email: 'new@example.com',
});
expect(result.isNew).toBe(true);
expect(result.matchedBy).toHaveLength(0);
});
it('should normalize email to lowercase', async () => {
prisma.customerProfile.findUnique.mockResolvedValue(null);
prisma.customerProfile.create.mockResolvedValue({ id: '123' });
await service.resolve({
tenantId: 'tenant_1',
email: ' TEST@Example.COM ',
});
expect(prisma.customerProfile.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
email: 'test@example.com',
}),
}),
);
});
it('should normalize SA phone to E.164', async () => {
prisma.customerProfile.findUnique.mockResolvedValue(null);
prisma.customerProfile.create.mockResolvedValue({ id: '123' });
await service.resolve({
tenantId: 'tenant_1',
phoneNumber: '082 123 4567',
});
expect(prisma.customerProfile.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
phoneNumber: '+27821234567',
}),
}),
);
});
});
});
Next Steps
After completing Phase 1, proceed to Phase 2: Event Integration.