Skip to main content

Sync & Audit Models

This document covers the sync tracking and audit data models for the CRM & Marketing Platform.


SyncLog

Log of events processed from external services.

model SyncLog {
id String @id @default(uuid())
tenantId String

// Event source
sourceService String // scl, teetime, messaging
eventType String // Full event type
eventId String // Original event ID
eventPayload Json? // Original payload (for debugging)

// Processing
status SyncStatus @default(PENDING)
processedAt DateTime?
attempts Int @default(0)
error String?

// Result
customerId String? // CustomerProfile created/updated
activityId String? // Activity created

createdAt DateTime @default(now())

@@unique([sourceService, eventId])
@@index([tenantId, status])
@@index([tenantId, sourceService, eventType])
@@index([createdAt])
}

Sync Status

StatusDescription
PENDINGAwaiting processing
PROCESSINGCurrently being processed
SUCCESSSuccessfully processed
FAILEDProcessing failed
SKIPPEDIntentionally skipped

Usage

On event received:

const syncLog = await prisma.syncLog.create({
data: {
tenantId: event.tenantId,
sourceService: 'scl',
eventType: event.type,
eventId: event.id,
eventPayload: event.payload,
status: 'PENDING',
}
});

On success:

await prisma.syncLog.update({
where: { id: syncLog.id },
data: {
status: 'SUCCESS',
processedAt: new Date(),
customerId: profile.id,
activityId: activity?.id,
}
});

On failure:

await prisma.syncLog.update({
where: { id: syncLog.id },
data: {
status: 'FAILED',
attempts: { increment: 1 },
error: error.message,
}
});

Idempotency

The unique constraint on (sourceService, eventId) ensures events are not processed twice:

const existing = await prisma.syncLog.findUnique({
where: {
sourceService_eventId: {
sourceService: event.source,
eventId: event.id,
}
}
});

if (existing?.status === 'SUCCESS') {
return; // Already processed
}

CrmAuditLog

Audit log for CRM operations.

model CrmAuditLog {
id String @id @default(uuid())
tenantId String

// Target
entityType String // CustomerProfile, Segment, Campaign, etc.
entityId String
action String // CREATE, UPDATE, DELETE, MERGE, etc.

// Changes
changes Json? // { field: { old, new } }
metadata Json? // Additional context

// Actor
performedBy String
performedAt DateTime @default(now())

// Context
ipAddress String?
userAgent String?

@@index([tenantId, entityType, entityId])
@@index([tenantId, performedAt])
@@index([tenantId, action])
}

Audit Actions

ActionDescription
CREATEEntity created
UPDATEEntity modified
DELETEEntity deleted (soft)
MERGEProfiles merged
IMPORTData imported
EXPORTData exported
ACCESSData accessed (for sensitive ops)

Changes Format

{
"email": {
"old": "john@example.com",
"new": "john.smith@example.com"
},
"tags": {
"old": ["vip"],
"new": ["vip", "golf-enthusiast"]
}
}

Usage

await prisma.crmAuditLog.create({
data: {
tenantId,
entityType: 'CustomerProfile',
entityId: profile.id,
action: 'UPDATE',
changes: {
email: { old: oldEmail, new: newEmail }
},
performedBy: userId,
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
}
});

DuplicateCandidate

Potential duplicate profiles flagged for review.

model DuplicateCandidate {
id String @id @default(uuid())
tenantId String

// The two profiles
profileAId String
profileBId String

// Match details
matchType String // email, phone, name_fuzzy, etc.
matchScore Float // 0-100 confidence
matchFields Json // Which fields matched

// Resolution
resolution DuplicateResolution @default(PENDING)
resolvedBy String?
resolvedAt DateTime?
notes String?

createdAt DateTime @default(now())

@@unique([profileAId, profileBId])
@@index([tenantId, resolution])
@@index([tenantId, matchScore])
}

Match Types

TypeDescriptionScore Range
emailSame email95-100
phoneSame phone85-95
email_phoneBoth match99-100
name_fuzzySimilar names60-80
name_email_domainName + same domain70-85

Match Fields Format

{
"email": {
"profileA": "john@example.com",
"profileB": "john@example.com",
"match": "exact"
},
"firstName": {
"profileA": "John",
"profileB": "Jon",
"match": "fuzzy",
"similarity": 0.85
}
}

Resolution Flow

PENDING → MERGED (profiles combined)
→ NOT_DUPLICATE (false positive)
→ IGNORED (defer decision)

Detection

// After creating/updating a profile
async detectDuplicates(profileId: string): Promise<void> {
const profile = await this.getProfile(profileId);

// Find potential matches
const candidates = await this.findSimilarProfiles(profile);

for (const candidate of candidates) {
const matchResult = this.calculateMatchScore(profile, candidate);

if (matchResult.score >= 60) {
await prisma.duplicateCandidate.create({
data: {
tenantId: profile.tenantId,
profileAId: profile.id,
profileBId: candidate.id,
matchType: matchResult.type,
matchScore: matchResult.score,
matchFields: matchResult.fields,
}
});
}
}
}

Index Strategy

Query PatternIndex
Sync by status(tenantId, status)
Sync by service(tenantId, sourceService, eventType)
Event deduplication(sourceService, eventId) UNIQUE
Recent sync logs(createdAt)
Audit by entity(tenantId, entityType, entityId)
Audit by time(tenantId, performedAt)
Audit by action(tenantId, action)
Pending duplicates(tenantId, resolution)
High-confidence duplicates(tenantId, matchScore)

Retention Policies

TableRetentionReason
SyncLog90 daysDebugging, idempotency
CrmAuditLog7 yearsCompliance, GDPR
DuplicateCandidateUntil resolvedManual review

Cleanup Job

// Run daily
async cleanupOldSyncLogs(): Promise<void> {
const cutoff = subDays(new Date(), 90);

await prisma.syncLog.deleteMany({
where: {
createdAt: { lt: cutoff },
status: { in: ['SUCCESS', 'SKIPPED'] }
}
});
}