Skip to main content

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.