From 4b51e386e39ae38d6ea52eb87858de36fa45ab46 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Mon, 23 Feb 2026 13:57:32 +0100 Subject: [PATCH] feat: Static segments --- apps/api/src/controllers/Segments.ts | 61 +++++- apps/api/src/services/SegmentService.ts | 200 +++++++++++++++--- apps/web/src/components/ContactPicker.tsx | 152 +++++++++++++ apps/web/src/pages/segments/[id].tsx | 197 +++++++++++++---- apps/web/src/pages/segments/index.tsx | 17 +- apps/web/src/pages/segments/new.tsx | 93 +++++++- .../migration.sql | 6 + packages/db/prisma/schema.prisma | 12 +- packages/shared/src/schemas/index.ts | 7 +- packages/types/src/segments/index.ts | 6 +- 10 files changed, 664 insertions(+), 87 deletions(-) create mode 100644 apps/web/src/components/ContactPicker.tsx create mode 100644 packages/db/prisma/migrations/20260223123035_add_static_segment/migration.sql diff --git a/apps/api/src/controllers/Segments.ts b/apps/api/src/controllers/Segments.ts index 7556add..afe37aa 100644 --- a/apps/api/src/controllers/Segments.ts +++ b/apps/api/src/controllers/Segments.ts @@ -72,20 +72,23 @@ export class Segments { @CatchAsync public async create(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth; - const {name, description, condition, trackMembership} = req.body; + const {name, description, type, condition, trackMembership} = req.body; if (!name) { return res.status(400).json({error: 'Name is required'}); } - if (!condition || typeof condition !== 'object') { - return res.status(400).json({error: 'Condition is required and must be an object'}); + const segmentType = type ?? 'DYNAMIC'; + + if (segmentType === 'DYNAMIC' && (!condition || typeof condition !== 'object')) { + return res.status(400).json({error: 'Condition is required and must be an object for DYNAMIC segments'}); } const segment = await SegmentService.create(auth.projectId!, { name, description, - condition, + type: segmentType, + condition: segmentType === 'DYNAMIC' ? condition : undefined, trackMembership, }); @@ -142,6 +145,56 @@ export class Segments { return res.status(204).send(); } + /** + * POST /segments/:id/members + * Add contacts to a static segment by email + */ + @Post(':id/members') + @Middleware([requireAuth, requireEmailVerified]) + @CatchAsync + public async addMembers(req: Request, res: Response, _next: NextFunction) { + const auth = res.locals.auth; + const segmentId = req.params.id; + const {emails} = req.body; + + if (!segmentId) { + return res.status(400).json({error: 'Segment ID is required'}); + } + + if (!Array.isArray(emails) || emails.length === 0) { + return res.status(400).json({error: 'emails must be a non-empty array'}); + } + + const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails); + + return res.status(200).json(result); + } + + /** + * DELETE /segments/:id/members + * Remove contacts from a static segment by email + */ + @Delete(':id/members') + @Middleware([requireAuth, requireEmailVerified]) + @CatchAsync + public async removeMembers(req: Request, res: Response, _next: NextFunction) { + const auth = res.locals.auth; + const segmentId = req.params.id; + const {emails} = req.body; + + if (!segmentId) { + return res.status(400).json({error: 'Segment ID is required'}); + } + + if (!Array.isArray(emails) || emails.length === 0) { + return res.status(400).json({error: 'emails must be a non-empty array'}); + } + + const result = await SegmentService.removeContacts(auth.projectId!, segmentId, emails); + + return res.status(200).json(result); + } + /** * POST /segments/:id/compute * Recompute segment membership for all contacts diff --git a/apps/api/src/services/SegmentService.ts b/apps/api/src/services/SegmentService.ts index 962ddf7..6c1a722 100644 --- a/apps/api/src/services/SegmentService.ts +++ b/apps/api/src/services/SegmentService.ts @@ -1,5 +1,5 @@ import {type Contact, Prisma, type Segment} from '@plunk/db'; -import type {FilterCondition, FilterGroup, PaginatedResponse, SegmentFilter} from '@plunk/types'; +import type {FilterCondition, FilterGroup, PaginatedResponse, SegmentFilter, SegmentType} from '@plunk/types'; import {fromPrismaJson, toPrismaJson} from '@plunk/types'; import signale from 'signale'; @@ -66,11 +66,33 @@ export class SegmentService { pageSize = 20, ): Promise> { const segment = await this.get(projectId, segmentId); - const condition = fromPrismaJson(segment.condition); - - const where = this.buildWhereClause(projectId, condition); const skip = (page - 1) * pageSize; + if (segment.type === 'STATIC') { + // For static segments, query via SegmentMembership records + const [memberships, total] = await Promise.all([ + prisma.segmentMembership.findMany({ + where: {segmentId, exitedAt: null}, + include: {contact: true}, + skip, + take: pageSize, + orderBy: {enteredAt: 'desc'}, + }), + prisma.segmentMembership.count({where: {segmentId, exitedAt: null}}), + ]); + + return { + data: memberships.map(m => m.contact), + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }; + } + + const condition = fromPrismaJson(segment.condition); + const where = this.buildWhereClause(projectId, condition); + const [contacts, total] = await Promise.all([ prisma.contact.findMany({ where, @@ -98,23 +120,35 @@ export class SegmentService { data: { name: string; description?: string; - condition: FilterCondition; + type?: SegmentType; + condition?: FilterCondition; trackMembership?: boolean; }, ): Promise { - // Validate condition - this.validateCondition(data.condition); + const segmentType = data.type ?? 'DYNAMIC'; + let memberCount = 0; + let conditionJson: Prisma.InputJsonValue | typeof Prisma.JsonNull = Prisma.JsonNull; - // Compute initial member count - const where = this.buildWhereClause(projectId, data.condition); - const memberCount = await prisma.contact.count({where}); + if (segmentType === 'DYNAMIC') { + if (!data.condition) { + throw new HttpException(400, 'Condition is required for DYNAMIC segments'); + } + // Validate condition + this.validateCondition(data.condition); + + // Compute initial member count + const where = this.buildWhereClause(projectId, data.condition); + memberCount = await prisma.contact.count({where}); + conditionJson = toPrismaJson(data.condition); + } const segment = await prisma.segment.create({ data: { projectId, name: data.name, description: data.description, - condition: toPrismaJson(data.condition), + type: segmentType, + condition: conditionJson, trackMembership: data.trackMembership ?? false, memberCount, }, @@ -145,12 +179,7 @@ export class SegmentService { }, ): Promise { // First verify segment exists and belongs to project - await this.get(projectId, segmentId); - - // Validate condition if provided - if (data.condition) { - this.validateCondition(data.condition); - } + const existing = await this.get(projectId, segmentId); const updateData: Prisma.SegmentUpdateInput = {}; @@ -160,7 +189,9 @@ export class SegmentService { if (data.description !== undefined) { updateData.description = data.description; } - if (data.condition !== undefined) { + if (data.condition !== undefined && existing.type !== 'STATIC') { + // Validate condition if provided (only for DYNAMIC segments) + this.validateCondition(data.condition); updateData.condition = toPrismaJson(data.condition); // Recompute member count when condition changes @@ -229,10 +260,16 @@ export class SegmentService { */ public static async refreshMemberCount(projectId: string, segmentId: string): Promise { const segment = await this.get(projectId, segmentId); - const condition = fromPrismaJson(segment.condition); - const where = this.buildWhereClause(projectId, condition); - const memberCount = await prisma.contact.count({where}); + let memberCount: number; + + if (segment.type === 'STATIC') { + memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}}); + } else { + const condition = fromPrismaJson(segment.condition); + const where = this.buildWhereClause(projectId, condition); + memberCount = await prisma.contact.count({where}); + } await prisma.segment.update({ where: {id: segmentId}, @@ -249,7 +286,7 @@ export class SegmentService { public static async refreshAllMemberCounts(projectId: string): Promise { const segments = await prisma.segment.findMany({ where: {projectId}, - select: {id: true, condition: true}, + select: {id: true, type: true, condition: true}, }); // Process in batches to avoid overwhelming the database @@ -260,9 +297,17 @@ export class SegmentService { await Promise.all( batch.map(async segment => { try { - const condition = fromPrismaJson(segment.condition); - const where = this.buildWhereClause(projectId, condition); - const memberCount = await prisma.contact.count({where}); + let memberCount: number; + + if (segment.type === 'STATIC') { + memberCount = await prisma.segmentMembership.count({ + where: {segmentId: segment.id, exitedAt: null}, + }); + } else { + const condition = fromPrismaJson(segment.condition); + const where = this.buildWhereClause(projectId, condition); + memberCount = await prisma.contact.count({where}); + } await prisma.segment.update({ where: {id: segment.id}, @@ -276,6 +321,104 @@ export class SegmentService { } } + /** + * Add contacts to a static segment by email + */ + public static async addContacts( + projectId: string, + segmentId: string, + emails: string[], + ): Promise<{added: number; notFound: string[]}> { + const segment = await this.get(projectId, segmentId); + + if (segment.type !== 'STATIC') { + throw new HttpException(400, 'Can only add contacts to STATIC segments'); + } + + // Look up contacts by email (case-insensitive) + const contacts = await prisma.contact.findMany({ + where: { + projectId, + email: {in: emails, mode: 'insensitive'}, + }, + select: {id: true, email: true}, + }); + + const foundEmails = new Set(contacts.map(c => c.email.toLowerCase())); + const notFound = emails.filter(e => !foundEmails.has(e.toLowerCase())); + + if (contacts.length > 0) { + // Check for existing memberships (to reactivate vs create new) + const existingMemberships = await prisma.segmentMembership.findMany({ + where: {segmentId, contactId: {in: contacts.map(c => c.id)}}, + select: {contactId: true}, + }); + const existingIds = new Set(existingMemberships.map(m => m.contactId)); + + const newContactIds = contacts.filter(c => !existingIds.has(c.id)).map(c => c.id); + const reEntryIds = contacts.filter(c => existingIds.has(c.id)).map(c => c.id); + + if (newContactIds.length > 0) { + await prisma.segmentMembership.createMany({ + data: newContactIds.map(contactId => ({segmentId, contactId, enteredAt: new Date()})), + skipDuplicates: true, + }); + } + + if (reEntryIds.length > 0) { + await prisma.segmentMembership.updateMany({ + where: {segmentId, contactId: {in: reEntryIds}}, + data: {exitedAt: null, enteredAt: new Date()}, + }); + } + + // Update member count + const memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}}); + await prisma.segment.update({where: {id: segmentId}, data: {memberCount}}); + } + + return {added: contacts.length, notFound}; + } + + /** + * Remove contacts from a static segment by email + */ + public static async removeContacts( + projectId: string, + segmentId: string, + emails: string[], + ): Promise<{removed: number}> { + const segment = await this.get(projectId, segmentId); + + if (segment.type !== 'STATIC') { + throw new HttpException(400, 'Can only remove contacts from STATIC segments'); + } + + // Look up contacts by email + const contacts = await prisma.contact.findMany({ + where: { + projectId, + email: {in: emails, mode: 'insensitive'}, + }, + select: {id: true}, + }); + + if (contacts.length > 0) { + const contactIds = contacts.map(c => c.id); + + await prisma.segmentMembership.updateMany({ + where: {segmentId, contactId: {in: contactIds}, exitedAt: null}, + data: {exitedAt: new Date()}, + }); + + // Update member count + const memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}}); + await prisma.segment.update({where: {id: segmentId}, data: {memberCount}}); + } + + return {removed: contacts.length}; + } + /** * Compute or recompute segment membership for all contacts * Now uses cursor-based pagination for memory efficiency with large contact lists @@ -290,6 +433,13 @@ export class SegmentService { throw new HttpException(400, 'Segment does not have membership tracking enabled'); } + if (segment.type === 'STATIC') { + // For static segments, just update the count from memberships — no contact scanning + const total = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}}); + await prisma.segment.update({where: {id: segmentId}, data: {memberCount: total}}); + return {added: 0, removed: 0, total}; + } + const condition = fromPrismaJson(segment.condition); const where = this.buildWhereClause(projectId, condition); diff --git a/apps/web/src/components/ContactPicker.tsx b/apps/web/src/components/ContactPicker.tsx new file mode 100644 index 0000000..8407ce9 --- /dev/null +++ b/apps/web/src/components/ContactPicker.tsx @@ -0,0 +1,152 @@ +import type {Contact} from '@plunk/db'; +import type {CursorPaginatedResponse} from '@plunk/types'; +import {Input, Popover, PopoverContent, PopoverTrigger} from '@plunk/ui'; +import {Check, ChevronsUpDown, MailCheck, MailX, Search, X} from 'lucide-react'; +import {useEffect, useRef, useState} from 'react'; +import useSWR from 'swr'; + +interface ContactPickerProps { + /** Currently selected emails */ + selected: string[]; + /** Called when selection changes */ + onChange: (emails: string[]) => void; + /** Emails already in the segment (shown as disabled) */ + existing?: string[]; + placeholder?: string; +} + +/** + * Searchable multi-select contact picker backed by the /contacts API. + * Only fetches when the user types (safe for large contact lists). + */ +export function ContactPicker({ + selected, + onChange, + existing = [], + placeholder = 'Search contacts...', +}: ContactPickerProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const debounceRef = useRef | null>(null); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => setDebouncedSearch(search), 300); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [search]); + + // Only fetch when there's a search term — avoids loading all contacts on open + const {data, isLoading} = useSWR>( + open && debouncedSearch.length > 0 ? `/contacts?limit=20&search=${encodeURIComponent(debouncedSearch)}` : null, + {revalidateOnFocus: false}, + ); + + const contacts = data?.data ?? []; + + const toggle = (email: string) => { + if (selected.includes(email)) { + onChange(selected.filter(e => e !== email)); + } else { + onChange([...selected, email]); + } + }; + + return ( +
+ { setOpen(v); if (!v) setSearch(''); }}> + + + + + {/* Search input */} +
+ + setSearch(e.target.value)} + className="border-0 p-0 h-8 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm" + autoFocus + /> +
+ + {/* Results */} +
+ {debouncedSearch.length === 0 ? ( +

Type to search contacts

+ ) : isLoading ? ( +

Searching...

+ ) : contacts.length === 0 ? ( +

No contacts found

+ ) : ( + contacts.map(contact => { + const isSelected = selected.includes(contact.email); + const isExisting = existing.includes(contact.email); + + return ( + + ); + }) + )} +
+
+
+ + {/* Selected chips */} + {selected.length > 0 && ( +
+ {selected.map(email => ( + + {email} + + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/segments/[id].tsx b/apps/web/src/pages/segments/[id].tsx index 895001b..b6a5417 100644 --- a/apps/web/src/pages/segments/[id].tsx +++ b/apps/web/src/pages/segments/[id].tsx @@ -13,7 +13,7 @@ import type {Contact, Segment} from '@plunk/db'; import type {PaginatedResponse} from '@plunk/types'; import {DashboardLayout} from '../../components/DashboardLayout'; import {network} from '../../lib/network'; -import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react'; +import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, UserMinus, Users} from 'lucide-react'; import Link from 'next/link'; import {useRouter} from 'next/router'; import {useEffect, useState} from 'react'; @@ -22,8 +22,12 @@ import useSWR from 'swr'; import type {FilterCondition} from '@plunk/types'; import {SegmentSchemas} from '@plunk/shared'; import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder'; +import {ContactPicker} from '../../components/ContactPicker'; import dayjs from 'dayjs'; +type SegmentType = 'DYNAMIC' | 'STATIC'; +type SegmentWithType = Segment & {type: SegmentType}; + // Count total filters in a condition (recursive) function countFilters(condition: FilterCondition): number { let count = 0; @@ -40,11 +44,13 @@ export default function SegmentDetailPage() { const router = useRouter(); const {id} = router.query; - const {data: segment, mutate, isLoading} = useSWR(id ? `/segments/${id}` : null); + const {data: segment, mutate, isLoading} = useSWR(id ? `/segments/${id}` : null); const [contactsPage, setContactsPage] = useState(1); - const {data: contactsData, isLoading: isLoadingContacts} = useSWR>( - id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null, - ); + const { + data: contactsData, + isLoading: isLoadingContacts, + mutate: mutateContacts, + } = useSWR>(id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null); const [name, setName] = useState(''); const [description, setDescription] = useState(''); @@ -57,7 +63,12 @@ export default function SegmentDetailPage() { const [isComputing, setIsComputing] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); - useEffect(() => { + // Static segment member management + const [pickedEmails, setPickedEmails] = useState([]); + const [isAddingMembers, setIsAddingMembers] = useState(false); + const [removingEmail, setRemovingEmail] = useState(null); + + useEffect(() => { if (segment) { setName(segment.name); setDescription(segment.description || ''); @@ -79,7 +90,7 @@ export default function SegmentDetailPage() { await network.fetch('PATCH', `/segments/${id}`, { name, description: description || undefined, - condition, + ...(segment?.type !== 'STATIC' && {condition}), trackMembership, }); toast.success('Segment updated successfully'); @@ -112,6 +123,47 @@ export default function SegmentDetailPage() { } }; + const handleAddMembers = async () => { + if (pickedEmails.length === 0) { + toast.error('Select at least one contact'); + return; + } + + setIsAddingMembers(true); + try { + const result = await network.fetch<{added: number; notFound: string[]}, typeof SegmentSchemas.members>( + 'POST', + `/segments/${id}/members`, + {emails: pickedEmails}, + ); + + toast.success(`Added ${result.added} contact${result.added !== 1 ? 's' : ''} to segment`); + setPickedEmails([]); + void mutate(); + void mutateContacts(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to add contacts'); + } finally { + setIsAddingMembers(false); + } + }; + + const handleRemoveMember = async (email: string) => { + setRemovingEmail(email); + try { + await network.fetch<{removed: number}, typeof SegmentSchemas.members>('DELETE', `/segments/${id}/members`, { + emails: [email], + }); + toast.success(`Removed ${email} from segment`); + void mutate(); + void mutateContacts(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to remove contact'); + } finally { + setRemovingEmail(null); + } + }; + const handleDelete = async () => { try { await network.fetch('DELETE', `/segments/${id}`); @@ -166,6 +218,8 @@ export default function SegmentDetailPage() { ); } + const isStatic = segment.type === 'STATIC'; + return (
@@ -178,8 +232,17 @@ export default function SegmentDetailPage() {
-

{segment.name}

-

{segment.description}

+
+

{segment.name}

+ + {isStatic ? 'Static' : 'Dynamic'} + +
+ {segment.description &&

{segment.description}

}
+ + + )} + {/* Contacts */}
- Matching Contacts - Contacts that match this segment's filters + {isStatic ? 'Members' : 'Matching Contacts'} + + {isStatic ? 'Contacts in this static segment' : "Contacts that match this segment's filters"} +
- {trackMembership && ( + {!isStatic && trackMembership && (
- - - +
+ + + + {isStatic && ( + + )} +
))} @@ -355,24 +464,28 @@ export default function SegmentDetailPage() { {segment.memberCount} -
-
- - Filters -
- - {countFilters(segment.condition as unknown as FilterCondition)} - -
-
-
- - Groups -
- - {(segment.condition as unknown as FilterCondition)?.groups?.length || 0} - -
+ {!isStatic && ( + <> +
+
+ + Filters +
+ + {countFilters(segment.condition as unknown as FilterCondition)} + +
+
+
+ + Groups +
+ + {(segment.condition as unknown as FilterCondition)?.groups?.length || 0} + +
+ + )}
diff --git a/apps/web/src/pages/segments/index.tsx b/apps/web/src/pages/segments/index.tsx index 6439706..318741f 100644 --- a/apps/web/src/pages/segments/index.tsx +++ b/apps/web/src/pages/segments/index.tsx @@ -146,7 +146,18 @@ export default function SegmentsPage() {
- {segment.name} +
+ {segment.name} + + {(segment as unknown as {type: string}).type === 'STATIC' ? 'Static' : 'Dynamic'} + +
{segment.description && ( {segment.description} )} @@ -170,7 +181,9 @@ export default function SegmentsPage() { Filters
- {countFiltersInCondition(segment.condition)} + {(segment as unknown as {type: string}).type === 'STATIC' + ? '—' + : countFiltersInCondition(segment.condition)}
diff --git a/apps/web/src/pages/segments/new.tsx b/apps/web/src/pages/segments/new.tsx index ccc183d..720ae51 100644 --- a/apps/web/src/pages/segments/new.tsx +++ b/apps/web/src/pages/segments/new.tsx @@ -2,6 +2,7 @@ import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input import {NextSeo} from 'next-seo'; import {DashboardLayout} from '../../components/DashboardLayout'; import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder'; +import {ContactPicker} from '../../components/ContactPicker'; import {network} from '../../lib/network'; import {ArrowLeft, Save} from 'lucide-react'; import Link from 'next/link'; @@ -12,10 +13,13 @@ import type {FilterCondition} from '@plunk/types'; import type {Segment} from '@plunk/db'; import {SegmentSchemas} from '@plunk/shared'; +type SegmentType = 'DYNAMIC' | 'STATIC'; + export default function NewSegmentPage() { const router = useRouter(); const [name, setName] = useState(''); const [description, setDescription] = useState(''); + const [segmentType, setSegmentType] = useState('DYNAMIC'); const [trackMembership, setTrackMembership] = useState(false); const [condition, setCondition] = useState({ logic: 'AND', @@ -25,6 +29,7 @@ export default function NewSegmentPage() { }, ], }); + const [selectedContacts, setSelectedContacts] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = async (e: React.FormEvent) => { @@ -32,13 +37,33 @@ export default function NewSegmentPage() { setIsSubmitting(true); try { - await network.fetch('POST', '/segments', { + const segment = await network.fetch('POST', '/segments', { name, description: description || undefined, - condition, + type: segmentType, + condition: segmentType === 'DYNAMIC' ? condition : undefined, trackMembership, }); - toast.success('Segment created successfully'); + + // For static segments with pre-selected contacts, add them now + if (segmentType === 'STATIC' && selectedContacts.length > 0) { + try { + const result = await network.fetch<{added: number; notFound: string[]}, typeof SegmentSchemas.members>( + 'POST', + `/segments/${segment.id}/members`, + {emails: selectedContacts}, + ); + toast.success( + `Segment created with ${result.added} contact${result.added !== 1 ? 's' : ''}`, + ); + } catch { + // Segment was created; just warn about members + toast.warning('Segment created, but some contacts could not be added'); + } + } else { + toast.success('Segment created successfully'); + } + void router.push('/segments'); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to create segment'); @@ -61,10 +86,40 @@ export default function NewSegmentPage() {

Create Segment

-

Build complex audience filters with AND/OR logic

+

+ {segmentType === 'DYNAMIC' + ? 'Build complex audience filters with AND/OR logic' + : 'Manually curate a list of contacts'} +

+ {/* Type Toggle */} +
+ + +
+
{/* Basic Info */} @@ -118,12 +173,30 @@ export default function NewSegmentPage() { - {/* Filter Builder */} - - - - - + {/* Filter Builder or Contact Picker */} + {segmentType === 'DYNAMIC' ? ( + + + + + + ) : ( + + + Initial Members + + Optionally add contacts now — you can always add or remove members later + + + + + + + )} {/* Actions */}
diff --git a/packages/db/prisma/migrations/20260223123035_add_static_segment/migration.sql b/packages/db/prisma/migrations/20260223123035_add_static_segment/migration.sql new file mode 100644 index 0000000..55404eb --- /dev/null +++ b/packages/db/prisma/migrations/20260223123035_add_static_segment/migration.sql @@ -0,0 +1,6 @@ +-- CreateEnum +CREATE TYPE "SegmentType" AS ENUM ('DYNAMIC', 'STATIC'); + +-- AlterTable +ALTER TABLE "segments" ADD COLUMN "type" "SegmentType" NOT NULL DEFAULT 'DYNAMIC', +ALTER COLUMN "condition" DROP NOT NULL; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index f5dc63b..72152bf 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -199,8 +199,11 @@ model Segment { name String description String? - // Filter condition (evaluated dynamically) - condition Json + // Segment type: DYNAMIC (filter-based) or STATIC (manually managed) + type SegmentType @default(DYNAMIC) + + // Filter condition (evaluated dynamically, null for STATIC segments) + condition Json? // Nested filter structure with AND/OR logic: // { // logic: "OR", @@ -747,6 +750,11 @@ enum StepExecutionStatus { FAILED // Failed with error } +enum SegmentType { + DYNAMIC // Filter-based segment evaluated against contacts at query time + STATIC // Manually curated list of contacts managed via memberships +} + enum EmailSourceType { TRANSACTIONAL // Sent via API call CAMPAIGN // Sent as part of broadcast diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index b231984..7fa35b0 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -132,15 +132,20 @@ export const SegmentSchemas = { create: z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), - condition: filterConditionSchema, + type: z.enum(['DYNAMIC', 'STATIC']).default('DYNAMIC'), + condition: filterConditionSchema.optional(), trackMembership: z.boolean().default(false), }), update: z.object({ name: z.string().min(1).max(100).optional(), description: z.string().max(500).optional(), + type: z.enum(['DYNAMIC', 'STATIC']).optional(), condition: filterConditionSchema.optional(), trackMembership: z.boolean().optional(), }), + members: z.object({ + emails: z.array(z.string().email()).min(1).max(500), + }), }; export const TemplateSchemas = { diff --git a/packages/types/src/segments/index.ts b/packages/types/src/segments/index.ts index bde3d2c..8f525b0 100644 --- a/packages/types/src/segments/index.ts +++ b/packages/types/src/segments/index.ts @@ -2,6 +2,8 @@ * Segment and filter types */ +export type SegmentType = 'DYNAMIC' | 'STATIC'; + // Segment filter types export type SegmentFilterOperator = // Standard operators (for contact fields) @@ -45,13 +47,15 @@ export interface FilterCondition { export interface CreateSegmentData { name: string; description?: string; - condition: FilterCondition; + type?: SegmentType; + condition?: FilterCondition; trackMembership?: boolean; } export interface UpdateSegmentData { name?: string; description?: string; + type?: SegmentType; condition?: FilterCondition; trackMembership?: boolean; }