diff --git a/apps/api/src/services/SegmentService.ts b/apps/api/src/services/SegmentService.ts index 4d19983..fdcaf2a 100644 --- a/apps/api/src/services/SegmentService.ts +++ b/apps/api/src/services/SegmentService.ts @@ -458,7 +458,7 @@ export class SegmentService { // Handle JSON field paths (e.g., "data.plan") if (field.startsWith('data.')) { const jsonPath = field.substring(5); // Remove "data." prefix - return this.buildJsonFieldCondition(jsonPath, operator, value); + return this.buildJsonFieldCondition(jsonPath, operator, value, unit); } // Handle regular fields @@ -638,13 +638,38 @@ export class SegmentService { /** * Build condition for JSON fields (stored in contact.data) */ - private static buildJsonFieldCondition(jsonPath: string, operator: string, value: unknown): Prisma.ContactWhereInput { + private static buildJsonFieldCondition( + jsonPath: string, + operator: string, + value: unknown, + unit?: 'days' | 'hours' | 'minutes', + ): Prisma.ContactWhereInput { const path = jsonPath.split('.'); switch (operator) { case 'equals': + // For date strings, compare only the date portion (ignore time) + if (this.isDateString(value)) { + const {startOfDay, startOfNextDay} = this.getDateRange(String(value)); + return { + AND: [ + {data: {path, gte: startOfDay as Prisma.InputJsonValue}}, + {data: {path, lt: startOfNextDay as Prisma.InputJsonValue}}, + ], + }; + } return {data: {path, equals: value as Prisma.InputJsonValue}}; case 'notEquals': + // For date strings, exclude the entire day (not just exact timestamp) + if (this.isDateString(value)) { + const {startOfDay, startOfNextDay} = this.getDateRange(String(value)); + return { + OR: [ + {data: {path, lt: startOfDay as Prisma.InputJsonValue}}, + {data: {path, gte: startOfNextDay as Prisma.InputJsonValue}}, + ], + }; + } return {NOT: {data: {path, equals: value as Prisma.InputJsonValue}}}; case 'contains': return {data: {path, string_contains: String(value)}}; @@ -670,6 +695,20 @@ export class SegmentService { return { OR: [{data: {path, equals: Prisma.DbNull}}, {data: {path, equals: Prisma.JsonNull}}], }; + case 'within': { + // Note: Requires JSON date fields in ISO 8601 format for proper comparison + if (!unit) { + throw new HttpException(400, 'Unit is required for "within" operator'); + } + + // Calculate the "since" date (X time units ago from now) + const now = new Date(); + const milliseconds = this.getMilliseconds(value as number, unit); + const since = new Date(now.getTime() - milliseconds); + + // Use ISO string for lexicographic comparison in JSON + return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}}; + } default: throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`); } @@ -721,6 +760,28 @@ export class SegmentService { unit?: 'days' | 'hours' | 'minutes', ): Prisma.ContactWhereInput { switch (operator) { + case 'equals': { + // For date fields, compare only the date portion (ignore time) + if (this.isDateString(value)) { + const {startOfDay, startOfNextDay} = this.getDateRange(String(value)); + return { + AND: [{[field]: {gte: new Date(startOfDay)}}, {[field]: {lt: new Date(startOfNextDay)}}], + }; + } + // Exact timestamp match if not a date string + return {[field]: new Date(value as string | number | Date)}; + } + case 'notEquals': { + // For date fields, exclude the entire day (not just exact timestamp) + if (this.isDateString(value)) { + const {startOfDay, startOfNextDay} = this.getDateRange(String(value)); + return { + OR: [{[field]: {lt: new Date(startOfDay)}}, {[field]: {gte: new Date(startOfNextDay)}}], + }; + } + // Exclude exact timestamp if not a date string + return {NOT: {[field]: new Date(value as string | number | Date)}}; + } case 'greaterThan': return {[field]: {gt: new Date(value as string | number | Date)}}; case 'lessThan': @@ -762,6 +823,41 @@ export class SegmentService { } } + /** + * Check if a value is a date string in YYYY-MM-DD format + */ + private static isDateString(value: unknown): boolean { + if (typeof value !== 'string') return false; + // Match YYYY-MM-DD format (with optional time component) + const dateRegex = /^\d{4}-\d{2}-\d{2}(T|$)/; + if (!dateRegex.test(value)) return false; + // Verify it's a valid date + const date = new Date(value); + return !isNaN(date.getTime()); + } + + /** + * Get date range for a date string (start of day to start of next day in UTC) + * @param value - Date string in YYYY-MM-DD format + * @returns Object with startOfDay and startOfNextDay as ISO strings + */ + private static getDateRange(value: string): {startOfDay: string; startOfNextDay: string} { + // Extract just the date part (YYYY-MM-DD) + const dateStr = value.split('T')[0]; + const startOfDay = `${dateStr}T00:00:00.000Z`; + + if (!dateStr) { + throw new HttpException(400, `Invalid date string: ${value}`); + } + + // Calculate start of next day + const nextDay = new Date(dateStr); + nextDay.setUTCDate(nextDay.getUTCDate() + 1); + const startOfNextDay = nextDay.toISOString().split('T')[0] + 'T00:00:00.000Z'; + + return {startOfDay, startOfNextDay}; + } + /** * Build condition for event-based filters * Uses Prisma relations to efficiently query contacts who triggered specific events diff --git a/apps/api/src/services/__tests__/SegmentService.operators.test.ts b/apps/api/src/services/__tests__/SegmentService.operators.test.ts index 5a7d318..c19cd92 100644 --- a/apps/api/src/services/__tests__/SegmentService.operators.test.ts +++ b/apps/api/src/services/__tests__/SegmentService.operators.test.ts @@ -1,6 +1,6 @@ import {beforeEach, describe, expect, it} from 'vitest'; import {SegmentService} from '../SegmentService'; -import {factories} from '../../../../../test/helpers'; +import {factories, getPrismaClient} from '../../../../../test/helpers'; /** * Comprehensive Operator Tests for Segment Filtering @@ -760,6 +760,226 @@ describe('SegmentService - Comprehensive Operator Tests', () => { expect(ids).toContain(justNow.id); }); }); + + describe('within operator for JSON date fields', () => { + it('should match contacts with JSON date field within specified days', async () => { + // Create contact with recent date in JSON field + const recentDate = new Date(); + const match = await factories.createContact({ + projectId, + data: { + subscriptionEndDate: recentDate.toISOString(), + }, + }); + + // Create contact with old date + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 10); + await factories.createContact({ + projectId, + data: { + subscriptionEndDate: oldDate.toISOString(), + }, + }); + + const segment = await factories.createSegment(projectId, { + filters: [ + { + field: 'data.subscriptionEndDate', + operator: 'within', + value: 3, + unit: 'days', + }, + ], + }); + + const result = await SegmentService.getContacts(projectId, segment.id); + expect(result.contacts).toHaveLength(1); + expect(result.contacts[0].id).toBe(match.id); + }); + + it('should match contacts with JSON date field within specified hours', async () => { + const recentDate = new Date(); + const match = await factories.createContact({ + projectId, + data: { + lastLoginAt: recentDate.toISOString(), + }, + }); + + const segment = await factories.createSegment(projectId, { + filters: [ + { + field: 'data.lastLoginAt', + operator: 'within', + value: 24, + unit: 'hours', + }, + ], + }); + + const result = await SegmentService.getContacts(projectId, segment.id); + expect(result.contacts).toHaveLength(1); + expect(result.contacts[0].id).toBe(match.id); + }); + + it('should match contacts with JSON date field within specified minutes', async () => { + const justNow = new Date(); + const match = await factories.createContact({ + projectId, + data: { + verifiedAt: justNow.toISOString(), + }, + }); + + const segment = await factories.createSegment(projectId, { + filters: [ + { + field: 'data.verifiedAt', + operator: 'within', + value: 60, + unit: 'minutes', + }, + ], + }); + + const result = await SegmentService.getContacts(projectId, segment.id); + expect(result.contacts).toHaveLength(1); + expect(result.contacts[0].id).toBe(match.id); + }); + + it('should NOT match contacts with JSON date field outside the time range', async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 10); + await factories.createContact({ + projectId, + data: { + subscriptionEndDate: oldDate.toISOString(), + }, + }); + + const segment = await factories.createSegment(projectId, { + filters: [ + { + field: 'data.subscriptionEndDate', + operator: 'within', + value: 3, + unit: 'days', + }, + ], + }); + + const result = await SegmentService.getContacts(projectId, segment.id); + expect(result.contacts).toHaveLength(0); + }); + + it('should NOT match contacts where JSON date field does not exist', async () => { + await factories.createContact({ + projectId, + data: { + otherField: 'value', + }, + }); + + const segment = await factories.createSegment(projectId, { + filters: [ + { + field: 'data.subscriptionEndDate', + operator: 'within', + value: 3, + unit: 'days', + }, + ], + }); + + const result = await SegmentService.getContacts(projectId, segment.id); + expect(result.contacts).toHaveLength(0); + }); + + it('should handle null values in JSON date fields gracefully', async () => { + await factories.createContact({ + projectId, + data: { + subscriptionEndDate: null, + }, + }); + + const segment = await factories.createSegment(projectId, { + filters: [ + { + field: 'data.subscriptionEndDate', + operator: 'within', + value: 3, + unit: 'days', + }, + ], + }); + + const result = await SegmentService.getContacts(projectId, segment.id); + expect(result.contacts).toHaveLength(0); + }); + + it('should work correctly with combined filters (AND logic)', async () => { + const recentDate = new Date(); + const match = await factories.createContact({ + projectId, + subscribed: true, + data: { + plan: 'premium', + subscriptionEndDate: recentDate.toISOString(), + }, + }); + + await factories.createContact({ + projectId, + subscribed: false, + data: { + plan: 'premium', + subscriptionEndDate: recentDate.toISOString(), + }, + }); + + const segment = await factories.createSegment(projectId, { + filters: [ + {field: 'subscribed', operator: 'equals', value: true}, + {field: 'data.plan', operator: 'equals', value: 'premium'}, + { + field: 'data.subscriptionEndDate', + operator: 'within', + value: 3, + unit: 'days', + }, + ], + }); + + const result = await SegmentService.getContacts(projectId, segment.id); + expect(result.contacts).toHaveLength(1); + expect(result.contacts[0].id).toBe(match.id); + }); + + it('should require unit parameter for within operator', async () => { + await expect( + SegmentService.create(projectId, { + name: 'Invalid Segment', + condition: { + logic: 'AND', + groups: [ + { + filters: [ + { + field: 'data.subscriptionEndDate', + operator: 'within', + value: 3, + // unit intentionally omitted + } as any, + ], + }, + ], + }, + }), + ).rejects.toThrow(/operator requires a unit/i); + }); + }); }); // ======================================== @@ -812,6 +1032,229 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); }); + // ======================================== + // DATE-ONLY COMPARISON (equals/notEquals) + // ======================================== + describe('Date-Only Comparison for equals/notEquals', () => { + describe('JSON date fields', () => { + it('should match contacts with date on the same day (equals) - ignoring time', async () => { + const targetDate = '2025-01-15'; + const match1 = await factories.createContact({ + projectId, + data: { + subscriptionEndDate: '2025-01-15T08:30:00.000Z', // Morning + }, + }); + const match2 = await factories.createContact({ + projectId, + data: { + subscriptionEndDate: '2025-01-15T18:45:30.000Z', // Evening + }, + }); + const noMatch = await factories.createContact({ + projectId, + data: { + subscriptionEndDate: '2025-01-16T00:00:00.000Z', // Next day + }, + }); + + const segment = await factories.createSegment(projectId, { + filters: [ + { + field: 'data.subscriptionEndDate', + operator: 'equals', + value: targetDate, + }, + ], + }); + + const result = await SegmentService.getContacts(projectId, segment.id); + const ids = result.contacts.map(c => c.id); + expect(ids).toContain(match1.id); + expect(ids).toContain(match2.id); + expect(ids).not.toContain(noMatch.id); + }); + + it('should exclude contacts with date on the specified day (notEquals)', async () => { + const targetDate = '2025-01-15'; + const match1 = await factories.createContact({ + projectId, + data: { + subscriptionEndDate: '2025-01-14T23:59:59.999Z', // Day before + }, + }); + const match2 = await factories.createContact({ + projectId, + data: { + subscriptionEndDate: '2025-01-16T00:00:00.000Z', // Day after + }, + }); + const noMatch = await factories.createContact({ + projectId, + data: { + subscriptionEndDate: '2025-01-15T12:00:00.000Z', // Same day + }, + }); + + const segment = await factories.createSegment(projectId, { + filters: [ + { + field: 'data.subscriptionEndDate', + operator: 'notEquals', + value: targetDate, + }, + ], + }); + + const result = await SegmentService.getContacts(projectId, segment.id); + const ids = result.contacts.map(c => c.id); + expect(ids).toContain(match1.id); + expect(ids).toContain(match2.id); + expect(ids).not.toContain(noMatch.id); + }); + + it('should handle date string with time component (extracts date only)', async () => { + const targetDate = '2025-01-15T00:00:00.000Z'; // Has time component + const match = await factories.createContact({ + projectId, + data: { + subscriptionEndDate: '2025-01-15T23:59:59.999Z', // End of day + }, + }); + + const segment = await factories.createSegment(projectId, { + filters: [ + { + field: 'data.subscriptionEndDate', + operator: 'equals', + value: targetDate, + }, + ], + }); + + const result = await SegmentService.getContacts(projectId, segment.id); + const ids = result.contacts.map(c => c.id); + expect(ids).toContain(match.id); + }); + + it('should still do exact match for non-date strings', async () => { + const match = await factories.createContact({ + projectId, + data: { + plan: 'premium', + }, + }); + await factories.createContact({ + projectId, + data: { + plan: 'basic', + }, + }); + + const segment = await factories.createSegment(projectId, { + filters: [ + { + field: 'data.plan', + operator: 'equals', + value: 'premium', + }, + ], + }); + + const result = await SegmentService.getContacts(projectId, segment.id); + expect(result.contacts).toHaveLength(1); + expect(result.contacts[0].id).toBe(match.id); + }); + }); + + describe('Native date fields (createdAt)', () => { + it('should match contacts created on the same day (equals) - ignoring time', async () => { + // Create contacts on specific dates + const jan15Morning = new Date('2025-01-15T08:30:00.000Z'); + const jan15Evening = new Date('2025-01-15T18:45:30.000Z'); + const jan16 = new Date('2025-01-16T00:00:00.000Z'); + + const prisma = getPrismaClient(); + + const match1 = await factories.createContact({projectId}); + await prisma.contact.update({ + where: {id: match1.id}, + data: {createdAt: jan15Morning}, + }); + + const match2 = await factories.createContact({projectId}); + await prisma.contact.update({ + where: {id: match2.id}, + data: {createdAt: jan15Evening}, + }); + + const noMatch = await factories.createContact({projectId}); + await prisma.contact.update({ + where: {id: noMatch.id}, + data: {createdAt: jan16}, + }); + + const segment = await factories.createSegment(projectId, { + filters: [ + { + field: 'createdAt', + operator: 'equals', + value: '2025-01-15', + }, + ], + }); + + const result = await SegmentService.getContacts(projectId, segment.id); + const ids = result.contacts.map(c => c.id); + expect(ids).toContain(match1.id); + expect(ids).toContain(match2.id); + expect(ids).not.toContain(noMatch.id); + }); + + it('should exclude contacts created on the specified day (notEquals)', async () => { + const jan14 = new Date('2025-01-14T23:59:59.999Z'); + const jan15 = new Date('2025-01-15T12:00:00.000Z'); + const jan16 = new Date('2025-01-16T00:00:00.000Z'); + + const prisma = getPrismaClient(); + + const match1 = await factories.createContact({projectId}); + await prisma.contact.update({ + where: {id: match1.id}, + data: {createdAt: jan14}, + }); + + const match2 = await factories.createContact({projectId}); + await prisma.contact.update({ + where: {id: match2.id}, + data: {createdAt: jan16}, + }); + + const noMatch = await factories.createContact({projectId}); + await prisma.contact.update({ + where: {id: noMatch.id}, + data: {createdAt: jan15}, + }); + + const segment = await factories.createSegment(projectId, { + filters: [ + { + field: 'createdAt', + operator: 'notEquals', + value: '2025-01-15', + }, + ], + }); + + const result = await SegmentService.getContacts(projectId, segment.id); + const ids = result.contacts.map(c => c.id); + expect(ids).toContain(match1.id); + expect(ids).toContain(match2.id); + expect(ids).not.toContain(noMatch.id); + }); + }); + }); + // ======================================== // COMBINED OPERATORS (AND logic) // ========================================