diff --git a/apps/api/package.json b/apps/api/package.json index 2b4b579..5e61133 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,6 +7,7 @@ "dev": "bun run --hot src/server.ts", "build": "tsc", "start": "bun run src/server.ts", + "worker:sequences": "bun run src/workers/sequence-worker.ts", "check-types": "tsc --noEmit && tsc --noEmit -p test/tsconfig.json", "lint": "eslint .", "test": "TZ=UTC vitest run", diff --git a/apps/api/src/config/env.config.ts b/apps/api/src/config/env.config.ts index e4f5da2..8f694e8 100644 --- a/apps/api/src/config/env.config.ts +++ b/apps/api/src/config/env.config.ts @@ -11,6 +11,8 @@ const rawEnv = { RESEND_FROM_EMAIL: process.env.RESEND_FROM_EMAIL, GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, + SEQUENCE_LIVE_SEND_ENABLED: process.env.SEQUENCE_LIVE_SEND_ENABLED, + GROQ_API_KEY: process.env.GROQ_API_KEY, }; const parsedEnv = apiEnvSchema.safeParse(rawEnv); diff --git a/apps/api/src/controllers/sequences.controller.ts b/apps/api/src/controllers/sequences.controller.ts new file mode 100644 index 0000000..fec9569 --- /dev/null +++ b/apps/api/src/controllers/sequences.controller.ts @@ -0,0 +1,213 @@ +import type { Context } from "hono"; +import type { + ConnectGmailInput, + CreateSequenceInput, + EnrollPeopleInput, + GenerateEmailContentInput, + ListEnrollmentsQuery, + ListSequencesQuery, + UpdateSequenceInput, + UpdateSequenceStepInput, +} from "@workspace/validators/schemas/sequence"; +import { STATUS_CODES } from "@/constants/status-codes.js"; +import { sendSuccess } from "@/lib/api-response.js"; +import { getSessionWorkspaceId } from "@/lib/workspace.js"; +import { + archiveSequence, + completeSequenceTask, + confirmUnsubscribe, + connectGmail, + createSequence, + deleteSequence, + disconnectGmail, + enrollPeople, + generateSequenceEmailContent, + getSequence, + getSequenceDashboard, + listGmailIntegrations, + listSequenceActivity, + listSequenceEnrollments, + listSequences, + pauseEnrollment, + previewUnsubscribe, + publishSequence, + resumeEnrollment, + updateSequence, + updateSequenceStep, +} from "@/services/sequence.service.js"; + +export async function listSequencesController(c: Context, query: ListSequencesQuery) { + return sendSuccess(c, await listSequences(getSessionWorkspaceId(c), query), STATUS_CODES.OK); +} + +export async function createSequenceController(c: Context, input: CreateSequenceInput) { + const workspaceId = getSessionWorkspaceId(c); + const user = c.get("user"); + return sendSuccess(c, { sequence: await createSequence(workspaceId, user.id, input) }, STATUS_CODES.CREATED); +} + +export async function getSequenceController(c: Context, sequenceId: string) { + return sendSuccess(c, await getSequence(getSessionWorkspaceId(c), sequenceId), STATUS_CODES.OK); +} + +export async function updateSequenceController( + c: Context, + sequenceId: string, + input: UpdateSequenceInput, +) { + return sendSuccess( + c, + await updateSequence(getSessionWorkspaceId(c), sequenceId, input), + STATUS_CODES.OK, + ); +} + +export async function updateSequenceStepController( + c: Context, + sequenceId: string, + stepId: string, + input: UpdateSequenceStepInput, +) { + return sendSuccess( + c, + { step: await updateSequenceStep(getSessionWorkspaceId(c), sequenceId, stepId, input) }, + STATUS_CODES.OK, + ); +} + +export async function publishSequenceController(c: Context, sequenceId: string) { + const workspaceId = getSessionWorkspaceId(c); + const user = c.get("user"); + return sendSuccess( + c, + { version: await publishSequence(workspaceId, sequenceId, user.id) }, + STATUS_CODES.OK, + ); +} + +export async function archiveSequenceController(c: Context, sequenceId: string) { + return sendSuccess( + c, + { sequence: await archiveSequence(getSessionWorkspaceId(c), sequenceId) }, + STATUS_CODES.OK, + ); +} + +export async function deleteSequenceController(c: Context, sequenceId: string) { + return sendSuccess( + c, + { sequence: await deleteSequence(getSessionWorkspaceId(c), sequenceId) }, + STATUS_CODES.OK, + ); +} + +export async function enrollPeopleController( + c: Context, + sequenceId: string, + input: EnrollPeopleInput, +) { + const workspaceId = getSessionWorkspaceId(c); + const user = c.get("user"); + return sendSuccess( + c, + { enrollments: await enrollPeople(workspaceId, sequenceId, user.id, input) }, + STATUS_CODES.CREATED, + ); +} + +export async function listSequenceEnrollmentsController( + c: Context, + sequenceId: string, + query: ListEnrollmentsQuery, +) { + return sendSuccess( + c, + { enrollments: await listSequenceEnrollments(getSessionWorkspaceId(c), sequenceId, query) }, + STATUS_CODES.OK, + ); +} + +export async function listSequenceActivityController(c: Context, sequenceId: string) { + return sendSuccess( + c, + { activity: await listSequenceActivity(getSessionWorkspaceId(c), sequenceId) }, + STATUS_CODES.OK, + ); +} + +export async function pauseEnrollmentController(c: Context, enrollmentId: string) { + return sendSuccess( + c, + { enrollment: await pauseEnrollment(getSessionWorkspaceId(c), enrollmentId) }, + STATUS_CODES.OK, + ); +} + +export async function resumeEnrollmentController(c: Context, enrollmentId: string) { + return sendSuccess( + c, + { enrollment: await resumeEnrollment(getSessionWorkspaceId(c), enrollmentId) }, + STATUS_CODES.OK, + ); +} + +export async function completeSequenceTaskController(c: Context, taskId: string) { + return sendSuccess( + c, + { task: await completeSequenceTask(getSessionWorkspaceId(c), taskId) }, + STATUS_CODES.OK, + ); +} + +export async function getSequenceDashboardController(c: Context) { + return sendSuccess(c, await getSequenceDashboard(getSessionWorkspaceId(c)), STATUS_CODES.OK); +} + +export async function listGmailIntegrationsController(c: Context) { + const user = c.get("user"); + return sendSuccess( + c, + { gmailIntegrations: await listGmailIntegrations(getSessionWorkspaceId(c), user.id) }, + STATUS_CODES.OK, + ); +} + +export async function connectGmailController(c: Context, input: ConnectGmailInput) { + const user = c.get("user"); + return sendSuccess( + c, + { gmailIntegration: await connectGmail(getSessionWorkspaceId(c), user.id, input) }, + STATUS_CODES.CREATED, + ); +} + +export async function disconnectGmailController(c: Context, gmailIntegrationId: string) { + const user = c.get("user"); + return sendSuccess( + c, + { + gmailIntegration: await disconnectGmail( + getSessionWorkspaceId(c), + user.id, + gmailIntegrationId, + ), + }, + STATUS_CODES.OK, + ); +} + +export async function generateEmailContentController(c: Context, input: GenerateEmailContentInput) { + return sendSuccess( + c, + { email: await generateSequenceEmailContent(input.prompt) }, + STATUS_CODES.OK, + ); +} + +export async function previewUnsubscribeController(c: Context, token: string) { + return sendSuccess(c, await previewUnsubscribe(token), STATUS_CODES.OK); +} + +export async function confirmUnsubscribeController(c: Context, token: string) { + return sendSuccess(c, await confirmUnsubscribe(token), STATUS_CODES.OK); +} diff --git a/apps/api/src/db/drizzle/0003_dusty_thor.sql b/apps/api/src/db/drizzle/0003_dusty_thor.sql new file mode 100644 index 0000000..f8ebcb1 --- /dev/null +++ b/apps/api/src/db/drizzle/0003_dusty_thor.sql @@ -0,0 +1,232 @@ +CREATE TYPE "public"."gmail_connection_status" AS ENUM('connected', 'reconnect_required', 'disconnected');--> statement-breakpoint +CREATE TYPE "public"."sequence_activity_type" AS ENUM('sequence_created', 'sequence_updated', 'sequence_published', 'sequence_archived', 'enrollment_started', 'email_sent', 'email_would_send', 'email_failed', 'reply_detected', 'unsubscribed', 'task_created', 'task_completed', 'paused', 'resumed', 'completed', 'gmail_warning');--> statement-breakpoint +CREATE TYPE "public"."sequence_enrollment_status" AS ENUM('active', 'paused', 'failed', 'replied', 'completed', 'unsubscribed');--> statement-breakpoint +CREATE TYPE "public"."sequence_enrollment_step_status" AS ENUM('pending', 'processing', 'completed', 'failed', 'skipped');--> statement-breakpoint +CREATE TYPE "public"."sequence_status" AS ENUM('draft', 'published', 'paused', 'archived');--> statement-breakpoint +CREATE TYPE "public"."sequence_step_type" AS ENUM('email', 'wait', 'linkedin_task', 'general_task');--> statement-breakpoint +CREATE TYPE "public"."sequence_task_status" AS ENUM('open', 'completed', 'canceled');--> statement-breakpoint +CREATE TYPE "public"."sequence_task_type" AS ENUM('linkedin_task', 'general_task', 'failure', 'gmail_warning');--> statement-breakpoint +CREATE TABLE "gmail_integrations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "email" varchar(255) NOT NULL, + "status" "gmail_connection_status" DEFAULT 'connected' NOT NULL, + "granted_scopes" jsonb DEFAULT '[]'::jsonb NOT NULL, + "access_token_encrypted" text NOT NULL, + "refresh_token_encrypted" text NOT NULL, + "token_expires_at" timestamp, + "last_sync_at" timestamp, + "timezone" varchar(100) DEFAULT 'UTC' NOT NULL, + "sending_window" jsonb NOT NULL, + "daily_limit" integer DEFAULT 50 NOT NULL, + "hourly_limit" integer DEFAULT 10 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "gmail_integrations_workspace_user_email_unique" UNIQUE("workspace_id","user_id","email") +); +--> statement-breakpoint +CREATE TABLE "sequence_activity_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "sequence_id" uuid, + "enrollment_id" uuid, + "person_id" uuid, + "type" "sequence_activity_type" NOT NULL, + "message" text NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sequence_enrollment_steps" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "enrollment_id" uuid NOT NULL, + "step_id" uuid, + "type" "sequence_step_type" NOT NULL, + "name" varchar(255) NOT NULL, + "position" integer NOT NULL, + "step_snapshot" jsonb NOT NULL, + "status" "sequence_enrollment_step_status" DEFAULT 'pending' NOT NULL, + "due_at" timestamp, + "attempts" integer DEFAULT 0 NOT NULL, + "next_attempt_at" timestamp, + "processed_at" timestamp, + "last_error" text, + "gmail_message_id" text, + "gmail_thread_id" text, + "created_task_id" uuid, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "sequence_enrollment_steps_enrollment_position_unique" UNIQUE("enrollment_id","position") +); +--> statement-breakpoint +CREATE TABLE "sequence_enrollments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "sequence_id" uuid NOT NULL, + "version_id" uuid NOT NULL, + "person_id" uuid NOT NULL, + "gmail_integration_id" uuid, + "enrolled_by_id" uuid, + "status" "sequence_enrollment_status" DEFAULT 'active' NOT NULL, + "current_position" integer DEFAULT 0 NOT NULL, + "next_step_due_at" timestamp, + "last_error" text, + "paused_at" timestamp, + "completed_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sequence_steps" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "sequence_id" uuid NOT NULL, + "type" "sequence_step_type" NOT NULL, + "name" varchar(255) NOT NULL, + "position" integer NOT NULL, + "config" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "sequence_steps_sequence_position_unique" UNIQUE("sequence_id","position") +); +--> statement-breakpoint +CREATE TABLE "sequence_suppressions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "email" varchar(255) NOT NULL, + "reason" varchar(100) DEFAULT 'unsubscribe' NOT NULL, + "source" varchar(100) DEFAULT 'public_unsubscribe' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "sequence_suppressions_workspace_email_unique" UNIQUE("workspace_id","email") +); +--> statement-breakpoint +CREATE TABLE "sequence_tasks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "sequence_id" uuid, + "enrollment_id" uuid, + "enrollment_step_id" uuid, + "person_id" uuid, + "assigned_to_id" uuid, + "type" "sequence_task_type" NOT NULL, + "status" "sequence_task_status" DEFAULT 'open' NOT NULL, + "title" varchar(255) NOT NULL, + "body" text DEFAULT '' NOT NULL, + "due_at" timestamp NOT NULL, + "completed_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sequence_unsubscribe_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "sequence_id" uuid NOT NULL, + "enrollment_id" uuid NOT NULL, + "person_id" uuid, + "email" varchar(255) NOT NULL, + "token_hash" text NOT NULL, + "consumed_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "sequence_unsubscribe_tokens_hash_unique" UNIQUE("token_hash") +); +--> statement-breakpoint +CREATE TABLE "sequence_versions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "sequence_id" uuid NOT NULL, + "version_number" integer NOT NULL, + "name" varchar(255) NOT NULL, + "steps_snapshot" jsonb DEFAULT '[]'::jsonb NOT NULL, + "published_by_id" uuid, + "published_at" timestamp DEFAULT now() NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "sequence_versions_sequence_version_unique" UNIQUE("sequence_id","version_number") +); +--> statement-breakpoint +CREATE TABLE "sequences" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "created_by_id" uuid, + "latest_published_version_id" uuid, + "name" varchar(255) NOT NULL, + "status" "sequence_status" DEFAULT 'draft' NOT NULL, + "timezone" varchar(100) DEFAULT 'UTC' NOT NULL, + "sending_window" jsonb NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "gmail_integrations" ADD CONSTRAINT "gmail_integrations_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "gmail_integrations" ADD CONSTRAINT "gmail_integrations_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_activity_events" ADD CONSTRAINT "sequence_activity_events_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_activity_events" ADD CONSTRAINT "sequence_activity_events_sequence_id_sequences_id_fk" FOREIGN KEY ("sequence_id") REFERENCES "public"."sequences"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_activity_events" ADD CONSTRAINT "sequence_activity_events_enrollment_id_sequence_enrollments_id_fk" FOREIGN KEY ("enrollment_id") REFERENCES "public"."sequence_enrollments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_activity_events" ADD CONSTRAINT "sequence_activity_events_person_id_people_id_fk" FOREIGN KEY ("person_id") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_enrollment_steps" ADD CONSTRAINT "sequence_enrollment_steps_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_enrollment_steps" ADD CONSTRAINT "sequence_enrollment_steps_enrollment_id_sequence_enrollments_id_fk" FOREIGN KEY ("enrollment_id") REFERENCES "public"."sequence_enrollments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_enrollments" ADD CONSTRAINT "sequence_enrollments_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_enrollments" ADD CONSTRAINT "sequence_enrollments_sequence_id_sequences_id_fk" FOREIGN KEY ("sequence_id") REFERENCES "public"."sequences"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_enrollments" ADD CONSTRAINT "sequence_enrollments_version_id_sequence_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "public"."sequence_versions"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_enrollments" ADD CONSTRAINT "sequence_enrollments_person_id_people_id_fk" FOREIGN KEY ("person_id") REFERENCES "public"."people"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_enrollments" ADD CONSTRAINT "sequence_enrollments_gmail_integration_id_gmail_integrations_id_fk" FOREIGN KEY ("gmail_integration_id") REFERENCES "public"."gmail_integrations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_enrollments" ADD CONSTRAINT "sequence_enrollments_enrolled_by_id_user_id_fk" FOREIGN KEY ("enrolled_by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_steps" ADD CONSTRAINT "sequence_steps_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_steps" ADD CONSTRAINT "sequence_steps_sequence_id_sequences_id_fk" FOREIGN KEY ("sequence_id") REFERENCES "public"."sequences"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_suppressions" ADD CONSTRAINT "sequence_suppressions_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_tasks" ADD CONSTRAINT "sequence_tasks_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_tasks" ADD CONSTRAINT "sequence_tasks_sequence_id_sequences_id_fk" FOREIGN KEY ("sequence_id") REFERENCES "public"."sequences"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_tasks" ADD CONSTRAINT "sequence_tasks_enrollment_id_sequence_enrollments_id_fk" FOREIGN KEY ("enrollment_id") REFERENCES "public"."sequence_enrollments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_tasks" ADD CONSTRAINT "sequence_tasks_enrollment_step_id_sequence_enrollment_steps_id_fk" FOREIGN KEY ("enrollment_step_id") REFERENCES "public"."sequence_enrollment_steps"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_tasks" ADD CONSTRAINT "sequence_tasks_person_id_people_id_fk" FOREIGN KEY ("person_id") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_tasks" ADD CONSTRAINT "sequence_tasks_assigned_to_id_user_id_fk" FOREIGN KEY ("assigned_to_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_unsubscribe_tokens" ADD CONSTRAINT "sequence_unsubscribe_tokens_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_unsubscribe_tokens" ADD CONSTRAINT "sequence_unsubscribe_tokens_sequence_id_sequences_id_fk" FOREIGN KEY ("sequence_id") REFERENCES "public"."sequences"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_unsubscribe_tokens" ADD CONSTRAINT "sequence_unsubscribe_tokens_enrollment_id_sequence_enrollments_id_fk" FOREIGN KEY ("enrollment_id") REFERENCES "public"."sequence_enrollments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_unsubscribe_tokens" ADD CONSTRAINT "sequence_unsubscribe_tokens_person_id_people_id_fk" FOREIGN KEY ("person_id") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_versions" ADD CONSTRAINT "sequence_versions_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_versions" ADD CONSTRAINT "sequence_versions_sequence_id_sequences_id_fk" FOREIGN KEY ("sequence_id") REFERENCES "public"."sequences"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_versions" ADD CONSTRAINT "sequence_versions_published_by_id_user_id_fk" FOREIGN KEY ("published_by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequences" ADD CONSTRAINT "sequences_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequences" ADD CONSTRAINT "sequences_created_by_id_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "gmail_integrations_workspace_id_idx" ON "gmail_integrations" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "gmail_integrations_user_id_idx" ON "gmail_integrations" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "gmail_integrations_status_idx" ON "gmail_integrations" USING btree ("status");--> statement-breakpoint +CREATE INDEX "sequence_activity_events_workspace_id_idx" ON "sequence_activity_events" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "sequence_activity_events_sequence_id_idx" ON "sequence_activity_events" USING btree ("sequence_id");--> statement-breakpoint +CREATE INDEX "sequence_activity_events_enrollment_id_idx" ON "sequence_activity_events" USING btree ("enrollment_id");--> statement-breakpoint +CREATE INDEX "sequence_activity_events_person_id_idx" ON "sequence_activity_events" USING btree ("person_id");--> statement-breakpoint +CREATE INDEX "sequence_activity_events_type_idx" ON "sequence_activity_events" USING btree ("type");--> statement-breakpoint +CREATE INDEX "sequence_activity_events_created_at_idx" ON "sequence_activity_events" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "sequence_enrollment_steps_workspace_id_idx" ON "sequence_enrollment_steps" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "sequence_enrollment_steps_enrollment_id_idx" ON "sequence_enrollment_steps" USING btree ("enrollment_id");--> statement-breakpoint +CREATE INDEX "sequence_enrollment_steps_status_due_idx" ON "sequence_enrollment_steps" USING btree ("status","due_at");--> statement-breakpoint +CREATE INDEX "sequence_enrollment_steps_thread_idx" ON "sequence_enrollment_steps" USING btree ("gmail_thread_id");--> statement-breakpoint +CREATE INDEX "sequence_enrollments_workspace_id_idx" ON "sequence_enrollments" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "sequence_enrollments_sequence_id_idx" ON "sequence_enrollments" USING btree ("sequence_id");--> statement-breakpoint +CREATE INDEX "sequence_enrollments_version_id_idx" ON "sequence_enrollments" USING btree ("version_id");--> statement-breakpoint +CREATE INDEX "sequence_enrollments_person_id_idx" ON "sequence_enrollments" USING btree ("person_id");--> statement-breakpoint +CREATE INDEX "sequence_enrollments_gmail_integration_id_idx" ON "sequence_enrollments" USING btree ("gmail_integration_id");--> statement-breakpoint +CREATE INDEX "sequence_enrollments_status_due_idx" ON "sequence_enrollments" USING btree ("status","next_step_due_at");--> statement-breakpoint +CREATE INDEX "sequence_steps_workspace_id_idx" ON "sequence_steps" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "sequence_steps_sequence_id_idx" ON "sequence_steps" USING btree ("sequence_id");--> statement-breakpoint +CREATE INDEX "sequence_suppressions_workspace_id_idx" ON "sequence_suppressions" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "sequence_suppressions_email_idx" ON "sequence_suppressions" USING btree ("email");--> statement-breakpoint +CREATE INDEX "sequence_tasks_workspace_id_idx" ON "sequence_tasks" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "sequence_tasks_sequence_id_idx" ON "sequence_tasks" USING btree ("sequence_id");--> statement-breakpoint +CREATE INDEX "sequence_tasks_enrollment_id_idx" ON "sequence_tasks" USING btree ("enrollment_id");--> statement-breakpoint +CREATE INDEX "sequence_tasks_enrollment_step_id_idx" ON "sequence_tasks" USING btree ("enrollment_step_id");--> statement-breakpoint +CREATE INDEX "sequence_tasks_person_id_idx" ON "sequence_tasks" USING btree ("person_id");--> statement-breakpoint +CREATE INDEX "sequence_tasks_assigned_to_id_idx" ON "sequence_tasks" USING btree ("assigned_to_id");--> statement-breakpoint +CREATE INDEX "sequence_tasks_status_due_idx" ON "sequence_tasks" USING btree ("status","due_at");--> statement-breakpoint +CREATE INDEX "sequence_unsubscribe_tokens_workspace_id_idx" ON "sequence_unsubscribe_tokens" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "sequence_unsubscribe_tokens_enrollment_id_idx" ON "sequence_unsubscribe_tokens" USING btree ("enrollment_id");--> statement-breakpoint +CREATE INDEX "sequence_unsubscribe_tokens_email_idx" ON "sequence_unsubscribe_tokens" USING btree ("email");--> statement-breakpoint +CREATE INDEX "sequence_versions_workspace_id_idx" ON "sequence_versions" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "sequence_versions_sequence_id_idx" ON "sequence_versions" USING btree ("sequence_id");--> statement-breakpoint +CREATE INDEX "sequences_workspace_id_idx" ON "sequences" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "sequences_created_by_id_idx" ON "sequences" USING btree ("created_by_id");--> statement-breakpoint +CREATE INDEX "sequences_status_idx" ON "sequences" USING btree ("status"); \ No newline at end of file diff --git a/apps/api/src/db/drizzle/meta/0003_snapshot.json b/apps/api/src/db/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..6d77ed7 --- /dev/null +++ b/apps/api/src/db/drizzle/meta/0003_snapshot.json @@ -0,0 +1,3743 @@ +{ + "id": "8c70f1a0-a2c7-493e-8fea-63858e646e63", + "prevId": "487034d0-60af-45cf-9304-6040ca7616e0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_provider_idx": { + "name": "account_provider_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crm_tour_completed_at": { + "name": "crm_tour_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_custom_field_definitions": { + "name": "crm_custom_field_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "crm_custom_field_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "crm_custom_field_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "crm_custom_field_definitions_workspace_entity_idx": { + "name": "crm_custom_field_definitions_workspace_entity_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "crm_custom_field_definitions_workspace_id_idx": { + "name": "crm_custom_field_definitions_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "crm_custom_field_definitions_workspace_id_workspaces_id_fk": { + "name": "crm_custom_field_definitions_workspace_id_workspaces_id_fk", + "tableFrom": "crm_custom_field_definitions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "crm_custom_field_definitions_workspace_entity_label_unique": { + "name": "crm_custom_field_definitions_workspace_entity_label_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "entity_type", + "label" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "person_id": { + "name": "person_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "stage": { + "name": "stage", + "type": "deal_stage", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "close_date": { + "name": "close_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_workspace_id_idx": { + "name": "deals_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_person_id_idx": { + "name": "deals_person_id_idx", + "columns": [ + { + "expression": "person_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_org_id_idx": { + "name": "deals_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_owner_id_idx": { + "name": "deals_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_stage_idx": { + "name": "deals_stage_idx", + "columns": [ + { + "expression": "stage", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_close_date_idx": { + "name": "deals_close_date_idx", + "columns": [ + { + "expression": "close_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_workspace_id_workspaces_id_fk": { + "name": "deals_workspace_id_workspaces_id_fk", + "tableFrom": "deals", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deals_person_id_people_id_fk": { + "name": "deals_person_id_people_id_fk", + "tableFrom": "deals", + "tableTo": "people", + "columnsFrom": [ + "person_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "deals_org_id_org_id_fk": { + "name": "deals_org_id_org_id_fk", + "tableFrom": "deals", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "deals_owner_id_user_id_fk": { + "name": "deals_owner_id_user_id_fk", + "tableFrom": "deals", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org": { + "name": "org", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "industry": { + "name": "industry", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_workspace_id_idx": { + "name": "org_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_owner_id_idx": { + "name": "org_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_workspace_id_workspaces_id_fk": { + "name": "org_workspace_id_workspaces_id_fk", + "tableFrom": "org", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_owner_id_user_id_fk": { + "name": "org_owner_id_user_id_fk", + "tableFrom": "org", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_workspace_name_unique": { + "name": "org_workspace_name_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.people": { + "name": "people", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "job_title": { + "name": "job_title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "linkedin_url": { + "name": "linkedin_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "people_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'lead'" + }, + "source": { + "name": "source", + "type": "people_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "last_contacted_at": { + "name": "last_contacted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "people_workspace_id_idx": { + "name": "people_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "people_org_id_idx": { + "name": "people_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "people_owner_id_idx": { + "name": "people_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "people_email_idx": { + "name": "people_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "people_status_idx": { + "name": "people_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "people_workspace_id_workspaces_id_fk": { + "name": "people_workspace_id_workspaces_id_fk", + "tableFrom": "people", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "people_org_id_org_id_fk": { + "name": "people_org_id_org_id_fk", + "tableFrom": "people", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "people_owner_id_user_id_fk": { + "name": "people_owner_id_user_id_fk", + "tableFrom": "people", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "people_workspace_email_unique": { + "name": "people_workspace_email_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gmail_integrations": { + "name": "gmail_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "gmail_connection_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'connected'" + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "access_token_encrypted": { + "name": "access_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "sending_window": { + "name": "sending_window", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "daily_limit": { + "name": "daily_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "hourly_limit": { + "name": "hourly_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "gmail_integrations_workspace_id_idx": { + "name": "gmail_integrations_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gmail_integrations_user_id_idx": { + "name": "gmail_integrations_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gmail_integrations_status_idx": { + "name": "gmail_integrations_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gmail_integrations_workspace_id_workspaces_id_fk": { + "name": "gmail_integrations_workspace_id_workspaces_id_fk", + "tableFrom": "gmail_integrations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gmail_integrations_user_id_user_id_fk": { + "name": "gmail_integrations_user_id_user_id_fk", + "tableFrom": "gmail_integrations", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "gmail_integrations_workspace_user_email_unique": { + "name": "gmail_integrations_workspace_user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "user_id", + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sequence_activity_events": { + "name": "sequence_activity_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enrollment_id": { + "name": "enrollment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "person_id": { + "name": "person_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "sequence_activity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sequence_activity_events_workspace_id_idx": { + "name": "sequence_activity_events_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_activity_events_sequence_id_idx": { + "name": "sequence_activity_events_sequence_id_idx", + "columns": [ + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_activity_events_enrollment_id_idx": { + "name": "sequence_activity_events_enrollment_id_idx", + "columns": [ + { + "expression": "enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_activity_events_person_id_idx": { + "name": "sequence_activity_events_person_id_idx", + "columns": [ + { + "expression": "person_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_activity_events_type_idx": { + "name": "sequence_activity_events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_activity_events_created_at_idx": { + "name": "sequence_activity_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sequence_activity_events_workspace_id_workspaces_id_fk": { + "name": "sequence_activity_events_workspace_id_workspaces_id_fk", + "tableFrom": "sequence_activity_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_activity_events_sequence_id_sequences_id_fk": { + "name": "sequence_activity_events_sequence_id_sequences_id_fk", + "tableFrom": "sequence_activity_events", + "tableTo": "sequences", + "columnsFrom": [ + "sequence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_activity_events_enrollment_id_sequence_enrollments_id_fk": { + "name": "sequence_activity_events_enrollment_id_sequence_enrollments_id_fk", + "tableFrom": "sequence_activity_events", + "tableTo": "sequence_enrollments", + "columnsFrom": [ + "enrollment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_activity_events_person_id_people_id_fk": { + "name": "sequence_activity_events_person_id_people_id_fk", + "tableFrom": "sequence_activity_events", + "tableTo": "people", + "columnsFrom": [ + "person_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sequence_enrollment_steps": { + "name": "sequence_enrollment_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enrollment_id": { + "name": "enrollment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "sequence_step_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "step_snapshot": { + "name": "step_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sequence_enrollment_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gmail_message_id": { + "name": "gmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gmail_thread_id": { + "name": "gmail_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_task_id": { + "name": "created_task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sequence_enrollment_steps_workspace_id_idx": { + "name": "sequence_enrollment_steps_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_enrollment_steps_enrollment_id_idx": { + "name": "sequence_enrollment_steps_enrollment_id_idx", + "columns": [ + { + "expression": "enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_enrollment_steps_status_due_idx": { + "name": "sequence_enrollment_steps_status_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_enrollment_steps_thread_idx": { + "name": "sequence_enrollment_steps_thread_idx", + "columns": [ + { + "expression": "gmail_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sequence_enrollment_steps_workspace_id_workspaces_id_fk": { + "name": "sequence_enrollment_steps_workspace_id_workspaces_id_fk", + "tableFrom": "sequence_enrollment_steps", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_enrollment_steps_enrollment_id_sequence_enrollments_id_fk": { + "name": "sequence_enrollment_steps_enrollment_id_sequence_enrollments_id_fk", + "tableFrom": "sequence_enrollment_steps", + "tableTo": "sequence_enrollments", + "columnsFrom": [ + "enrollment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sequence_enrollment_steps_enrollment_position_unique": { + "name": "sequence_enrollment_steps_enrollment_position_unique", + "nullsNotDistinct": false, + "columns": [ + "enrollment_id", + "position" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sequence_enrollments": { + "name": "sequence_enrollments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "person_id": { + "name": "person_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "gmail_integration_id": { + "name": "gmail_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enrolled_by_id": { + "name": "enrolled_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "sequence_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "current_position": { + "name": "current_position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_step_due_at": { + "name": "next_step_due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sequence_enrollments_workspace_id_idx": { + "name": "sequence_enrollments_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_enrollments_sequence_id_idx": { + "name": "sequence_enrollments_sequence_id_idx", + "columns": [ + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_enrollments_version_id_idx": { + "name": "sequence_enrollments_version_id_idx", + "columns": [ + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_enrollments_person_id_idx": { + "name": "sequence_enrollments_person_id_idx", + "columns": [ + { + "expression": "person_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_enrollments_gmail_integration_id_idx": { + "name": "sequence_enrollments_gmail_integration_id_idx", + "columns": [ + { + "expression": "gmail_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_enrollments_status_due_idx": { + "name": "sequence_enrollments_status_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_step_due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sequence_enrollments_workspace_id_workspaces_id_fk": { + "name": "sequence_enrollments_workspace_id_workspaces_id_fk", + "tableFrom": "sequence_enrollments", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_enrollments_sequence_id_sequences_id_fk": { + "name": "sequence_enrollments_sequence_id_sequences_id_fk", + "tableFrom": "sequence_enrollments", + "tableTo": "sequences", + "columnsFrom": [ + "sequence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_enrollments_version_id_sequence_versions_id_fk": { + "name": "sequence_enrollments_version_id_sequence_versions_id_fk", + "tableFrom": "sequence_enrollments", + "tableTo": "sequence_versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "sequence_enrollments_person_id_people_id_fk": { + "name": "sequence_enrollments_person_id_people_id_fk", + "tableFrom": "sequence_enrollments", + "tableTo": "people", + "columnsFrom": [ + "person_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_enrollments_gmail_integration_id_gmail_integrations_id_fk": { + "name": "sequence_enrollments_gmail_integration_id_gmail_integrations_id_fk", + "tableFrom": "sequence_enrollments", + "tableTo": "gmail_integrations", + "columnsFrom": [ + "gmail_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "sequence_enrollments_enrolled_by_id_user_id_fk": { + "name": "sequence_enrollments_enrolled_by_id_user_id_fk", + "tableFrom": "sequence_enrollments", + "tableTo": "user", + "columnsFrom": [ + "enrolled_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sequence_steps": { + "name": "sequence_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "sequence_step_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sequence_steps_workspace_id_idx": { + "name": "sequence_steps_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_steps_sequence_id_idx": { + "name": "sequence_steps_sequence_id_idx", + "columns": [ + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sequence_steps_workspace_id_workspaces_id_fk": { + "name": "sequence_steps_workspace_id_workspaces_id_fk", + "tableFrom": "sequence_steps", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_steps_sequence_id_sequences_id_fk": { + "name": "sequence_steps_sequence_id_sequences_id_fk", + "tableFrom": "sequence_steps", + "tableTo": "sequences", + "columnsFrom": [ + "sequence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sequence_steps_sequence_position_unique": { + "name": "sequence_steps_sequence_position_unique", + "nullsNotDistinct": false, + "columns": [ + "sequence_id", + "position" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sequence_suppressions": { + "name": "sequence_suppressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'unsubscribe'" + }, + "source": { + "name": "source", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'public_unsubscribe'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sequence_suppressions_workspace_id_idx": { + "name": "sequence_suppressions_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_suppressions_email_idx": { + "name": "sequence_suppressions_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sequence_suppressions_workspace_id_workspaces_id_fk": { + "name": "sequence_suppressions_workspace_id_workspaces_id_fk", + "tableFrom": "sequence_suppressions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sequence_suppressions_workspace_email_unique": { + "name": "sequence_suppressions_workspace_email_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sequence_tasks": { + "name": "sequence_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enrollment_id": { + "name": "enrollment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enrollment_step_id": { + "name": "enrollment_step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "person_id": { + "name": "person_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "sequence_task_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sequence_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sequence_tasks_workspace_id_idx": { + "name": "sequence_tasks_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_tasks_sequence_id_idx": { + "name": "sequence_tasks_sequence_id_idx", + "columns": [ + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_tasks_enrollment_id_idx": { + "name": "sequence_tasks_enrollment_id_idx", + "columns": [ + { + "expression": "enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_tasks_enrollment_step_id_idx": { + "name": "sequence_tasks_enrollment_step_id_idx", + "columns": [ + { + "expression": "enrollment_step_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_tasks_person_id_idx": { + "name": "sequence_tasks_person_id_idx", + "columns": [ + { + "expression": "person_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_tasks_assigned_to_id_idx": { + "name": "sequence_tasks_assigned_to_id_idx", + "columns": [ + { + "expression": "assigned_to_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_tasks_status_due_idx": { + "name": "sequence_tasks_status_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sequence_tasks_workspace_id_workspaces_id_fk": { + "name": "sequence_tasks_workspace_id_workspaces_id_fk", + "tableFrom": "sequence_tasks", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_tasks_sequence_id_sequences_id_fk": { + "name": "sequence_tasks_sequence_id_sequences_id_fk", + "tableFrom": "sequence_tasks", + "tableTo": "sequences", + "columnsFrom": [ + "sequence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_tasks_enrollment_id_sequence_enrollments_id_fk": { + "name": "sequence_tasks_enrollment_id_sequence_enrollments_id_fk", + "tableFrom": "sequence_tasks", + "tableTo": "sequence_enrollments", + "columnsFrom": [ + "enrollment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_tasks_enrollment_step_id_sequence_enrollment_steps_id_fk": { + "name": "sequence_tasks_enrollment_step_id_sequence_enrollment_steps_id_fk", + "tableFrom": "sequence_tasks", + "tableTo": "sequence_enrollment_steps", + "columnsFrom": [ + "enrollment_step_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "sequence_tasks_person_id_people_id_fk": { + "name": "sequence_tasks_person_id_people_id_fk", + "tableFrom": "sequence_tasks", + "tableTo": "people", + "columnsFrom": [ + "person_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "sequence_tasks_assigned_to_id_user_id_fk": { + "name": "sequence_tasks_assigned_to_id_user_id_fk", + "tableFrom": "sequence_tasks", + "tableTo": "user", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sequence_unsubscribe_tokens": { + "name": "sequence_unsubscribe_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enrollment_id": { + "name": "enrollment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "person_id": { + "name": "person_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sequence_unsubscribe_tokens_workspace_id_idx": { + "name": "sequence_unsubscribe_tokens_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_unsubscribe_tokens_enrollment_id_idx": { + "name": "sequence_unsubscribe_tokens_enrollment_id_idx", + "columns": [ + { + "expression": "enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_unsubscribe_tokens_email_idx": { + "name": "sequence_unsubscribe_tokens_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sequence_unsubscribe_tokens_workspace_id_workspaces_id_fk": { + "name": "sequence_unsubscribe_tokens_workspace_id_workspaces_id_fk", + "tableFrom": "sequence_unsubscribe_tokens", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_unsubscribe_tokens_sequence_id_sequences_id_fk": { + "name": "sequence_unsubscribe_tokens_sequence_id_sequences_id_fk", + "tableFrom": "sequence_unsubscribe_tokens", + "tableTo": "sequences", + "columnsFrom": [ + "sequence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_unsubscribe_tokens_enrollment_id_sequence_enrollments_id_fk": { + "name": "sequence_unsubscribe_tokens_enrollment_id_sequence_enrollments_id_fk", + "tableFrom": "sequence_unsubscribe_tokens", + "tableTo": "sequence_enrollments", + "columnsFrom": [ + "enrollment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_unsubscribe_tokens_person_id_people_id_fk": { + "name": "sequence_unsubscribe_tokens_person_id_people_id_fk", + "tableFrom": "sequence_unsubscribe_tokens", + "tableTo": "people", + "columnsFrom": [ + "person_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sequence_unsubscribe_tokens_hash_unique": { + "name": "sequence_unsubscribe_tokens_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sequence_versions": { + "name": "sequence_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "steps_snapshot": { + "name": "steps_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "published_by_id": { + "name": "published_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sequence_versions_workspace_id_idx": { + "name": "sequence_versions_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequence_versions_sequence_id_idx": { + "name": "sequence_versions_sequence_id_idx", + "columns": [ + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sequence_versions_workspace_id_workspaces_id_fk": { + "name": "sequence_versions_workspace_id_workspaces_id_fk", + "tableFrom": "sequence_versions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_versions_sequence_id_sequences_id_fk": { + "name": "sequence_versions_sequence_id_sequences_id_fk", + "tableFrom": "sequence_versions", + "tableTo": "sequences", + "columnsFrom": [ + "sequence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_versions_published_by_id_user_id_fk": { + "name": "sequence_versions_published_by_id_user_id_fk", + "tableFrom": "sequence_versions", + "tableTo": "user", + "columnsFrom": [ + "published_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sequence_versions_sequence_version_unique": { + "name": "sequence_versions_sequence_version_unique", + "nullsNotDistinct": false, + "columns": [ + "sequence_id", + "version_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sequences": { + "name": "sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "latest_published_version_id": { + "name": "latest_published_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sequence_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "sending_window": { + "name": "sending_window", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sequences_workspace_id_idx": { + "name": "sequences_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequences_created_by_id_idx": { + "name": "sequences_created_by_id_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sequences_status_idx": { + "name": "sequences_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sequences_workspace_id_workspaces_id_fk": { + "name": "sequences_workspace_id_workspaces_id_fk", + "tableFrom": "sequences", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequences_created_by_id_user_id_fk": { + "name": "sequences_created_by_id_user_id_fk", + "tableFrom": "sequences", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invites": { + "name": "workspace_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "workspace_invite_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "workspace_invite_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invites_workspace_id_idx": { + "name": "workspace_invites_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_email_idx": { + "name": "workspace_invites_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_status_idx": { + "name": "workspace_invites_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_expires_at_idx": { + "name": "workspace_invites_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_created_by_idx": { + "name": "workspace_invites_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_workspaces_id_fk": { + "name": "workspace_invites_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invites_created_by_user_id_fk": { + "name": "workspace_invites_created_by_user_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invites_token_unique": { + "name": "workspace_invites_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_members": { + "name": "workspace_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "workspace_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_workspace_id_idx": { + "name": "workspace_members_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_user_id_fk": { + "name": "workspace_members_user_id_user_id_fk", + "tableFrom": "workspace_members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_members_workspace_user_unique": { + "name": "workspace_members_workspace_user_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_owner_id_idx": { + "name": "workspaces_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_slug_idx": { + "name": "workspaces_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_user_id_fk": { + "name": "workspaces_owner_id_user_id_fk", + "tableFrom": "workspaces", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.crm_custom_field_entity_type": { + "name": "crm_custom_field_entity_type", + "schema": "public", + "values": [ + "people", + "org" + ] + }, + "public.crm_custom_field_type": { + "name": "crm_custom_field_type", + "schema": "public", + "values": [ + "text", + "number", + "select", + "dateTime" + ] + }, + "public.deal_stage": { + "name": "deal_stage", + "schema": "public", + "values": [ + "new", + "contacted", + "demo", + "proposal", + "won", + "lost" + ] + }, + "public.gmail_connection_status": { + "name": "gmail_connection_status", + "schema": "public", + "values": [ + "connected", + "reconnect_required", + "disconnected" + ] + }, + "public.people_source": { + "name": "people_source", + "schema": "public", + "values": [ + "manual", + "csv", + "api" + ] + }, + "public.people_status": { + "name": "people_status", + "schema": "public", + "values": [ + "lead", + "prospect", + "qualified", + "customer", + "churned" + ] + }, + "public.sequence_activity_type": { + "name": "sequence_activity_type", + "schema": "public", + "values": [ + "sequence_created", + "sequence_updated", + "sequence_published", + "sequence_archived", + "enrollment_started", + "email_sent", + "email_would_send", + "email_failed", + "reply_detected", + "unsubscribed", + "task_created", + "task_completed", + "paused", + "resumed", + "completed", + "gmail_warning" + ] + }, + "public.sequence_enrollment_status": { + "name": "sequence_enrollment_status", + "schema": "public", + "values": [ + "active", + "paused", + "failed", + "replied", + "completed", + "unsubscribed" + ] + }, + "public.sequence_enrollment_step_status": { + "name": "sequence_enrollment_step_status", + "schema": "public", + "values": [ + "pending", + "processing", + "completed", + "failed", + "skipped" + ] + }, + "public.sequence_status": { + "name": "sequence_status", + "schema": "public", + "values": [ + "draft", + "published", + "paused", + "archived" + ] + }, + "public.sequence_step_type": { + "name": "sequence_step_type", + "schema": "public", + "values": [ + "email", + "wait", + "linkedin_task", + "general_task" + ] + }, + "public.sequence_task_status": { + "name": "sequence_task_status", + "schema": "public", + "values": [ + "open", + "completed", + "canceled" + ] + }, + "public.sequence_task_type": { + "name": "sequence_task_type", + "schema": "public", + "values": [ + "linkedin_task", + "general_task", + "failure", + "gmail_warning" + ] + }, + "public.workspace_invite_role": { + "name": "workspace_invite_role", + "schema": "public", + "values": [ + "admin", + "member" + ] + }, + "public.workspace_invite_status": { + "name": "workspace_invite_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "rejected", + "canceled" + ] + }, + "public.workspace_role": { + "name": "workspace_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/drizzle/meta/_journal.json b/apps/api/src/db/drizzle/meta/_journal.json index 81063ba..69850d2 100644 --- a/apps/api/src/db/drizzle/meta/_journal.json +++ b/apps/api/src/db/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1783273332682, "tag": "0002_elite_tony_stark", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1783389401615, + "tag": "0003_dusty_thor", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema/enums.schema.ts b/apps/api/src/db/schema/enums.schema.ts index ad94825..e69aab1 100644 --- a/apps/api/src/db/schema/enums.schema.ts +++ b/apps/api/src/db/schema/enums.schema.ts @@ -41,3 +41,72 @@ export const crmCustomFieldTypeEnum = pgEnum("crm_custom_field_type", [ "select", "dateTime", ]); + +export const sequenceStatusEnum = pgEnum("sequence_status", [ + "draft", + "published", + "paused", + "archived", +]); + +export const sequenceStepTypeEnum = pgEnum("sequence_step_type", [ + "email", + "wait", + "linkedin_task", + "general_task", +]); + +export const sequenceEnrollmentStatusEnum = pgEnum("sequence_enrollment_status", [ + "active", + "paused", + "failed", + "replied", + "completed", + "unsubscribed", +]); + +export const sequenceEnrollmentStepStatusEnum = pgEnum("sequence_enrollment_step_status", [ + "pending", + "processing", + "completed", + "failed", + "skipped", +]); + +export const sequenceTaskStatusEnum = pgEnum("sequence_task_status", [ + "open", + "completed", + "canceled", +]); + +export const sequenceTaskTypeEnum = pgEnum("sequence_task_type", [ + "linkedin_task", + "general_task", + "failure", + "gmail_warning", +]); + +export const sequenceActivityTypeEnum = pgEnum("sequence_activity_type", [ + "sequence_created", + "sequence_updated", + "sequence_published", + "sequence_archived", + "enrollment_started", + "email_sent", + "email_would_send", + "email_failed", + "reply_detected", + "unsubscribed", + "task_created", + "task_completed", + "paused", + "resumed", + "completed", + "gmail_warning", +]); + +export const gmailConnectionStatusEnum = pgEnum("gmail_connection_status", [ + "connected", + "reconnect_required", + "disconnected", +]); diff --git a/apps/api/src/db/schema/index.ts b/apps/api/src/db/schema/index.ts index ebe5ba6..f3b1ce5 100644 --- a/apps/api/src/db/schema/index.ts +++ b/apps/api/src/db/schema/index.ts @@ -1,4 +1,5 @@ export * from "./auth.schema.js"; export * from "./workspace.schema.js"; export * from "./crm.schema.js"; +export * from "./sequence.schema.js"; export * from "./enums.schema.js"; diff --git a/apps/api/src/db/schema/sequence.schema.ts b/apps/api/src/db/schema/sequence.schema.ts new file mode 100644 index 0000000..2ab98c5 --- /dev/null +++ b/apps/api/src/db/schema/sequence.schema.ts @@ -0,0 +1,434 @@ +import { relations } from "drizzle-orm"; +import { + index, + integer, + jsonb, + pgTable, + text, + timestamp, + unique, + uuid, + varchar, +} from "drizzle-orm/pg-core"; +import { id, timestamps } from "./common.schema.js"; +import { user } from "./auth.schema.js"; +import { people } from "./crm.schema.js"; +import { workspaces } from "./workspace.schema.js"; +import { + gmailConnectionStatusEnum, + sequenceActivityTypeEnum, + sequenceEnrollmentStatusEnum, + sequenceEnrollmentStepStatusEnum, + sequenceStatusEnum, + sequenceStepTypeEnum, + sequenceTaskStatusEnum, + sequenceTaskTypeEnum, +} from "./enums.schema.js"; +import type { SequenceSendingWindow } from "@workspace/validators/schemas/sequence"; + +export type SequenceStepConfig = Record; + +export type SequenceStepSnapshot = { + id: string; + type: "email" | "wait" | "linkedin_task" | "general_task"; + name: string; + position: number; + config: SequenceStepConfig; +}; + +export const sequences = pgTable( + "sequences", + { + ...id, + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + createdById: uuid("created_by_id").references(() => user.id, { onDelete: "set null" }), + latestPublishedVersionId: uuid("latest_published_version_id"), + name: varchar("name", { length: 255 }).notNull(), + status: sequenceStatusEnum("status").notNull().default("draft"), + timezone: varchar("timezone", { length: 100 }).notNull().default("UTC"), + sendingWindow: jsonb("sending_window").$type().notNull(), + ...timestamps, + }, + (table) => [ + index("sequences_workspace_id_idx").on(table.workspaceId), + index("sequences_created_by_id_idx").on(table.createdById), + index("sequences_status_idx").on(table.status), + ], +); + +export const sequenceSteps = pgTable( + "sequence_steps", + { + ...id, + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + sequenceId: uuid("sequence_id") + .notNull() + .references(() => sequences.id, { onDelete: "cascade" }), + type: sequenceStepTypeEnum("type").notNull(), + name: varchar("name", { length: 255 }).notNull(), + position: integer("position").notNull(), + config: jsonb("config").$type().default({}).notNull(), + ...timestamps, + }, + (table) => [ + unique("sequence_steps_sequence_position_unique").on(table.sequenceId, table.position), + index("sequence_steps_workspace_id_idx").on(table.workspaceId), + index("sequence_steps_sequence_id_idx").on(table.sequenceId), + ], +); + +export const sequenceVersions = pgTable( + "sequence_versions", + { + ...id, + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + sequenceId: uuid("sequence_id") + .notNull() + .references(() => sequences.id, { onDelete: "cascade" }), + versionNumber: integer("version_number").notNull(), + name: varchar("name", { length: 255 }).notNull(), + stepsSnapshot: jsonb("steps_snapshot").$type().default([]).notNull(), + publishedById: uuid("published_by_id").references(() => user.id, { onDelete: "set null" }), + publishedAt: timestamp("published_at").defaultNow().notNull(), + ...timestamps, + }, + (table) => [ + unique("sequence_versions_sequence_version_unique").on(table.sequenceId, table.versionNumber), + index("sequence_versions_workspace_id_idx").on(table.workspaceId), + index("sequence_versions_sequence_id_idx").on(table.sequenceId), + ], +); + +export const gmailIntegrations = pgTable( + "gmail_integrations", + { + ...id, + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + email: varchar("email", { length: 255 }).notNull(), + status: gmailConnectionStatusEnum("status").notNull().default("connected"), + grantedScopes: jsonb("granted_scopes").$type().default([]).notNull(), + accessTokenEncrypted: text("access_token_encrypted").notNull(), + refreshTokenEncrypted: text("refresh_token_encrypted").notNull(), + tokenExpiresAt: timestamp("token_expires_at"), + lastSyncAt: timestamp("last_sync_at"), + timezone: varchar("timezone", { length: 100 }).notNull().default("UTC"), + sendingWindow: jsonb("sending_window").$type().notNull(), + dailyLimit: integer("daily_limit").notNull().default(50), + hourlyLimit: integer("hourly_limit").notNull().default(10), + ...timestamps, + }, + (table) => [ + unique("gmail_integrations_workspace_user_email_unique").on( + table.workspaceId, + table.userId, + table.email, + ), + index("gmail_integrations_workspace_id_idx").on(table.workspaceId), + index("gmail_integrations_user_id_idx").on(table.userId), + index("gmail_integrations_status_idx").on(table.status), + ], +); + +export const sequenceEnrollments = pgTable( + "sequence_enrollments", + { + ...id, + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + sequenceId: uuid("sequence_id") + .notNull() + .references(() => sequences.id, { onDelete: "cascade" }), + versionId: uuid("version_id") + .notNull() + .references(() => sequenceVersions.id, { onDelete: "restrict" }), + personId: uuid("person_id") + .notNull() + .references(() => people.id, { onDelete: "cascade" }), + gmailIntegrationId: uuid("gmail_integration_id").references(() => gmailIntegrations.id, { + onDelete: "set null", + }), + enrolledById: uuid("enrolled_by_id").references(() => user.id, { onDelete: "set null" }), + status: sequenceEnrollmentStatusEnum("status").notNull().default("active"), + currentPosition: integer("current_position").notNull().default(0), + nextStepDueAt: timestamp("next_step_due_at"), + lastError: text("last_error"), + pausedAt: timestamp("paused_at"), + completedAt: timestamp("completed_at"), + ...timestamps, + }, + (table) => [ + index("sequence_enrollments_workspace_id_idx").on(table.workspaceId), + index("sequence_enrollments_sequence_id_idx").on(table.sequenceId), + index("sequence_enrollments_version_id_idx").on(table.versionId), + index("sequence_enrollments_person_id_idx").on(table.personId), + index("sequence_enrollments_gmail_integration_id_idx").on(table.gmailIntegrationId), + index("sequence_enrollments_status_due_idx").on(table.status, table.nextStepDueAt), + ], +); + +export const sequenceEnrollmentSteps = pgTable( + "sequence_enrollment_steps", + { + ...id, + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + enrollmentId: uuid("enrollment_id") + .notNull() + .references(() => sequenceEnrollments.id, { onDelete: "cascade" }), + stepId: uuid("step_id"), + type: sequenceStepTypeEnum("type").notNull(), + name: varchar("name", { length: 255 }).notNull(), + position: integer("position").notNull(), + stepSnapshot: jsonb("step_snapshot").$type().notNull(), + status: sequenceEnrollmentStepStatusEnum("status").notNull().default("pending"), + dueAt: timestamp("due_at"), + attempts: integer("attempts").notNull().default(0), + nextAttemptAt: timestamp("next_attempt_at"), + processedAt: timestamp("processed_at"), + lastError: text("last_error"), + gmailMessageId: text("gmail_message_id"), + gmailThreadId: text("gmail_thread_id"), + createdTaskId: uuid("created_task_id"), + ...timestamps, + }, + (table) => [ + unique("sequence_enrollment_steps_enrollment_position_unique").on( + table.enrollmentId, + table.position, + ), + index("sequence_enrollment_steps_workspace_id_idx").on(table.workspaceId), + index("sequence_enrollment_steps_enrollment_id_idx").on(table.enrollmentId), + index("sequence_enrollment_steps_status_due_idx").on(table.status, table.dueAt), + index("sequence_enrollment_steps_thread_idx").on(table.gmailThreadId), + ], +); + +export const sequenceTasks = pgTable( + "sequence_tasks", + { + ...id, + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + sequenceId: uuid("sequence_id").references(() => sequences.id, { onDelete: "cascade" }), + enrollmentId: uuid("enrollment_id").references(() => sequenceEnrollments.id, { + onDelete: "cascade", + }), + enrollmentStepId: uuid("enrollment_step_id").references(() => sequenceEnrollmentSteps.id, { + onDelete: "set null", + }), + personId: uuid("person_id").references(() => people.id, { onDelete: "set null" }), + assignedToId: uuid("assigned_to_id").references(() => user.id, { onDelete: "set null" }), + type: sequenceTaskTypeEnum("type").notNull(), + status: sequenceTaskStatusEnum("status").notNull().default("open"), + title: varchar("title", { length: 255 }).notNull(), + body: text("body").notNull().default(""), + dueAt: timestamp("due_at").notNull(), + completedAt: timestamp("completed_at"), + ...timestamps, + }, + (table) => [ + index("sequence_tasks_workspace_id_idx").on(table.workspaceId), + index("sequence_tasks_sequence_id_idx").on(table.sequenceId), + index("sequence_tasks_enrollment_id_idx").on(table.enrollmentId), + index("sequence_tasks_enrollment_step_id_idx").on(table.enrollmentStepId), + index("sequence_tasks_person_id_idx").on(table.personId), + index("sequence_tasks_assigned_to_id_idx").on(table.assignedToId), + index("sequence_tasks_status_due_idx").on(table.status, table.dueAt), + ], +); + +export const sequenceActivityEvents = pgTable( + "sequence_activity_events", + { + ...id, + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + sequenceId: uuid("sequence_id").references(() => sequences.id, { onDelete: "cascade" }), + enrollmentId: uuid("enrollment_id").references(() => sequenceEnrollments.id, { + onDelete: "cascade", + }), + personId: uuid("person_id").references(() => people.id, { onDelete: "set null" }), + type: sequenceActivityTypeEnum("type").notNull(), + message: text("message").notNull(), + metadata: jsonb("metadata").$type>().default({}).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + index("sequence_activity_events_workspace_id_idx").on(table.workspaceId), + index("sequence_activity_events_sequence_id_idx").on(table.sequenceId), + index("sequence_activity_events_enrollment_id_idx").on(table.enrollmentId), + index("sequence_activity_events_person_id_idx").on(table.personId), + index("sequence_activity_events_type_idx").on(table.type), + index("sequence_activity_events_created_at_idx").on(table.createdAt), + ], +); + +export const sequenceSuppressions = pgTable( + "sequence_suppressions", + { + ...id, + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + email: varchar("email", { length: 255 }).notNull(), + reason: varchar("reason", { length: 100 }).notNull().default("unsubscribe"), + source: varchar("source", { length: 100 }).notNull().default("public_unsubscribe"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + unique("sequence_suppressions_workspace_email_unique").on(table.workspaceId, table.email), + index("sequence_suppressions_workspace_id_idx").on(table.workspaceId), + index("sequence_suppressions_email_idx").on(table.email), + ], +); + +export const sequenceUnsubscribeTokens = pgTable( + "sequence_unsubscribe_tokens", + { + ...id, + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + sequenceId: uuid("sequence_id") + .notNull() + .references(() => sequences.id, { onDelete: "cascade" }), + enrollmentId: uuid("enrollment_id") + .notNull() + .references(() => sequenceEnrollments.id, { onDelete: "cascade" }), + personId: uuid("person_id").references(() => people.id, { onDelete: "set null" }), + email: varchar("email", { length: 255 }).notNull(), + tokenHash: text("token_hash").notNull(), + consumedAt: timestamp("consumed_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + unique("sequence_unsubscribe_tokens_hash_unique").on(table.tokenHash), + index("sequence_unsubscribe_tokens_workspace_id_idx").on(table.workspaceId), + index("sequence_unsubscribe_tokens_enrollment_id_idx").on(table.enrollmentId), + index("sequence_unsubscribe_tokens_email_idx").on(table.email), + ], +); + +export const sequencesRelations = relations(sequences, ({ one, many }) => ({ + workspace: one(workspaces, { fields: [sequences.workspaceId], references: [workspaces.id] }), + createdBy: one(user, { fields: [sequences.createdById], references: [user.id] }), + steps: many(sequenceSteps), + versions: many(sequenceVersions), + enrollments: many(sequenceEnrollments), + activityEvents: many(sequenceActivityEvents), +})); + +export const sequenceStepsRelations = relations(sequenceSteps, ({ one }) => ({ + workspace: one(workspaces, { + fields: [sequenceSteps.workspaceId], + references: [workspaces.id], + }), + sequence: one(sequences, { fields: [sequenceSteps.sequenceId], references: [sequences.id] }), +})); + +export const sequenceVersionsRelations = relations(sequenceVersions, ({ one, many }) => ({ + workspace: one(workspaces, { + fields: [sequenceVersions.workspaceId], + references: [workspaces.id], + }), + sequence: one(sequences, { + fields: [sequenceVersions.sequenceId], + references: [sequences.id], + }), + publishedBy: one(user, { fields: [sequenceVersions.publishedById], references: [user.id] }), + enrollments: many(sequenceEnrollments), +})); + +export const gmailIntegrationsRelations = relations(gmailIntegrations, ({ one, many }) => ({ + workspace: one(workspaces, { + fields: [gmailIntegrations.workspaceId], + references: [workspaces.id], + }), + user: one(user, { fields: [gmailIntegrations.userId], references: [user.id] }), + enrollments: many(sequenceEnrollments), +})); + +export const sequenceEnrollmentsRelations = relations(sequenceEnrollments, ({ one, many }) => ({ + workspace: one(workspaces, { + fields: [sequenceEnrollments.workspaceId], + references: [workspaces.id], + }), + sequence: one(sequences, { + fields: [sequenceEnrollments.sequenceId], + references: [sequences.id], + }), + version: one(sequenceVersions, { + fields: [sequenceEnrollments.versionId], + references: [sequenceVersions.id], + }), + person: one(people, { fields: [sequenceEnrollments.personId], references: [people.id] }), + gmailIntegration: one(gmailIntegrations, { + fields: [sequenceEnrollments.gmailIntegrationId], + references: [gmailIntegrations.id], + }), + enrolledBy: one(user, { fields: [sequenceEnrollments.enrolledById], references: [user.id] }), + steps: many(sequenceEnrollmentSteps), + tasks: many(sequenceTasks), + activityEvents: many(sequenceActivityEvents), +})); + +export const sequenceEnrollmentStepsRelations = relations( + sequenceEnrollmentSteps, + ({ one, many }) => ({ + workspace: one(workspaces, { + fields: [sequenceEnrollmentSteps.workspaceId], + references: [workspaces.id], + }), + enrollment: one(sequenceEnrollments, { + fields: [sequenceEnrollmentSteps.enrollmentId], + references: [sequenceEnrollments.id], + }), + tasks: many(sequenceTasks), + }), +); + +export const sequenceTasksRelations = relations(sequenceTasks, ({ one }) => ({ + workspace: one(workspaces, { fields: [sequenceTasks.workspaceId], references: [workspaces.id] }), + sequence: one(sequences, { fields: [sequenceTasks.sequenceId], references: [sequences.id] }), + enrollment: one(sequenceEnrollments, { + fields: [sequenceTasks.enrollmentId], + references: [sequenceEnrollments.id], + }), + enrollmentStep: one(sequenceEnrollmentSteps, { + fields: [sequenceTasks.enrollmentStepId], + references: [sequenceEnrollmentSteps.id], + }), + person: one(people, { fields: [sequenceTasks.personId], references: [people.id] }), + assignedTo: one(user, { fields: [sequenceTasks.assignedToId], references: [user.id] }), +})); + +export const sequenceActivityEventsRelations = relations(sequenceActivityEvents, ({ one }) => ({ + workspace: one(workspaces, { + fields: [sequenceActivityEvents.workspaceId], + references: [workspaces.id], + }), + sequence: one(sequences, { + fields: [sequenceActivityEvents.sequenceId], + references: [sequences.id], + }), + enrollment: one(sequenceEnrollments, { + fields: [sequenceActivityEvents.enrollmentId], + references: [sequenceEnrollments.id], + }), + person: one(people, { fields: [sequenceActivityEvents.personId], references: [people.id] }), +})); diff --git a/apps/api/src/lib/token-crypto.ts b/apps/api/src/lib/token-crypto.ts new file mode 100644 index 0000000..e2e5981 --- /dev/null +++ b/apps/api/src/lib/token-crypto.ts @@ -0,0 +1,42 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; + +const ALGORITHM = "aes-256-gcm"; +const IV_BYTES = 12; + +function deriveKey(secret: string) { + return createHash("sha256").update(secret).digest(); +} + +export function createPublicToken() { + return randomBytes(32).toString("base64url"); +} + +export function hashPublicToken(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +export function encryptSecretToken(token: string, secret: string) { + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(ALGORITHM, deriveKey(secret), iv); + const encrypted = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return ["v1", iv.toString("base64url"), authTag.toString("base64url"), encrypted.toString("base64url")].join( + ":", + ); +} + +export function decryptSecretToken(encryptedToken: string, secret: string) { + const [version, ivValue, authTagValue, encryptedValue] = encryptedToken.split(":"); + if (version !== "v1" || !ivValue || !authTagValue || !encryptedValue) { + throw new Error("Unsupported encrypted token format"); + } + + const decipher = createDecipheriv(ALGORITHM, deriveKey(secret), Buffer.from(ivValue, "base64url")); + decipher.setAuthTag(Buffer.from(authTagValue, "base64url")); + + return Buffer.concat([ + decipher.update(Buffer.from(encryptedValue, "base64url")), + decipher.final(), + ]).toString("utf8"); +} diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 4482e9b..7297437 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -6,6 +6,8 @@ import { healthRoutes } from "./health.route.js"; import { orgRoutes } from "./org.route.js"; import { peopleRoutes } from "./people.route.js"; import { onboardingRoutes } from "./onboarding.route.js"; +import { sequenceRoutes } from "./sequences.route.js"; +import { sequenceUnsubscribeRoutes } from "./sequence-unsubscribe.route.js"; export function registerRoutes(app: Hono) { app.route("/api/auth", authRoutes); @@ -15,4 +17,6 @@ export function registerRoutes(app: Hono) { app.route("/deals", dealRoutes); app.route("/analytics", analyticsRoutes); app.route("/onboarding", onboardingRoutes); + app.route("/sequences", sequenceRoutes); + app.route("/sequence-unsubscribe", sequenceUnsubscribeRoutes); } diff --git a/apps/api/src/routes/sequence-unsubscribe.route.ts b/apps/api/src/routes/sequence-unsubscribe.route.ts new file mode 100644 index 0000000..93ae8fc --- /dev/null +++ b/apps/api/src/routes/sequence-unsubscribe.route.ts @@ -0,0 +1,18 @@ +import { Hono } from "hono"; +import { unsubscribeTokenParamsSchema } from "@workspace/validators/schemas/sequence"; +import { VALIDATION_TARGET } from "@/constants/validation-targets.js"; +import { + confirmUnsubscribeController, + previewUnsubscribeController, +} from "@/controllers/sequences.controller.js"; +import { validateRequest } from "@/middlewares/validate-request.js"; + +export const sequenceUnsubscribeRoutes = new Hono() + .get("/:token", validateRequest(VALIDATION_TARGET.PARAM, unsubscribeTokenParamsSchema), (c) => { + const { token } = c.req.valid(VALIDATION_TARGET.PARAM); + return previewUnsubscribeController(c, token); + }) + .post("/:token", validateRequest(VALIDATION_TARGET.PARAM, unsubscribeTokenParamsSchema), (c) => { + const { token } = c.req.valid(VALIDATION_TARGET.PARAM); + return confirmUnsubscribeController(c, token); + }); diff --git a/apps/api/src/routes/sequences.route.ts b/apps/api/src/routes/sequences.route.ts new file mode 100644 index 0000000..0eae179 --- /dev/null +++ b/apps/api/src/routes/sequences.route.ts @@ -0,0 +1,145 @@ +import { Hono } from "hono"; +import { + connectGmailSchema, + createSequenceSchema, + enrollPeopleSchema, + generateEmailContentSchema, + gmailIntegrationParamsSchema, + listEnrollmentsQuerySchema, + listSequencesQuerySchema, + sequenceEnrollmentParamsSchema, + sequenceParamsSchema, + sequenceStepParamsSchema, + sequenceTaskParamsSchema, + updateSequenceSchema, + updateSequenceStepSchema, +} from "@workspace/validators/schemas/sequence"; +import { VALIDATION_TARGET } from "@/constants/validation-targets.js"; +import { + archiveSequenceController, + completeSequenceTaskController, + connectGmailController, + createSequenceController, + deleteSequenceController, + disconnectGmailController, + enrollPeopleController, + generateEmailContentController, + getSequenceController, + getSequenceDashboardController, + listGmailIntegrationsController, + listSequenceActivityController, + listSequenceEnrollmentsController, + listSequencesController, + pauseEnrollmentController, + publishSequenceController, + resumeEnrollmentController, + updateSequenceController, + updateSequenceStepController, +} from "@/controllers/sequences.controller.js"; +import { authMiddleware } from "@/middlewares/auth-middleware.js"; +import { validateRequest } from "@/middlewares/validate-request.js"; + +export const sequenceRoutes = new Hono() + .use("*", authMiddleware) + .get("/dashboard", (c) => getSequenceDashboardController(c)) + .get("/gmail", (c) => listGmailIntegrationsController(c)) + .post("/gmail", validateRequest(VALIDATION_TARGET.JSON, connectGmailSchema), (c) => + connectGmailController(c, c.req.valid(VALIDATION_TARGET.JSON)), + ) + .delete( + "/gmail/:gmailIntegrationId", + validateRequest(VALIDATION_TARGET.PARAM, gmailIntegrationParamsSchema), + (c) => { + const { gmailIntegrationId } = c.req.valid(VALIDATION_TARGET.PARAM); + return disconnectGmailController(c, gmailIntegrationId); + }, + ) + .post("/generate-email", validateRequest(VALIDATION_TARGET.JSON, generateEmailContentSchema), (c) => + generateEmailContentController(c, c.req.valid(VALIDATION_TARGET.JSON)), + ) + .patch( + "/enrollments/:enrollmentId/pause", + validateRequest(VALIDATION_TARGET.PARAM, sequenceEnrollmentParamsSchema), + (c) => { + const { enrollmentId } = c.req.valid(VALIDATION_TARGET.PARAM); + return pauseEnrollmentController(c, enrollmentId); + }, + ) + .patch( + "/enrollments/:enrollmentId/resume", + validateRequest(VALIDATION_TARGET.PARAM, sequenceEnrollmentParamsSchema), + (c) => { + const { enrollmentId } = c.req.valid(VALIDATION_TARGET.PARAM); + return resumeEnrollmentController(c, enrollmentId); + }, + ) + .patch( + "/tasks/:taskId/complete", + validateRequest(VALIDATION_TARGET.PARAM, sequenceTaskParamsSchema), + (c) => { + const { taskId } = c.req.valid(VALIDATION_TARGET.PARAM); + return completeSequenceTaskController(c, taskId); + }, + ) + .get("/", validateRequest(VALIDATION_TARGET.QUERY, listSequencesQuerySchema), (c) => + listSequencesController(c, c.req.valid(VALIDATION_TARGET.QUERY)), + ) + .post("/", validateRequest(VALIDATION_TARGET.JSON, createSequenceSchema), (c) => + createSequenceController(c, c.req.valid(VALIDATION_TARGET.JSON)), + ) + .get("/:id", validateRequest(VALIDATION_TARGET.PARAM, sequenceParamsSchema), (c) => { + const { id } = c.req.valid(VALIDATION_TARGET.PARAM); + return getSequenceController(c, id); + }) + .patch( + "/:id", + validateRequest(VALIDATION_TARGET.PARAM, sequenceParamsSchema), + validateRequest(VALIDATION_TARGET.JSON, updateSequenceSchema), + (c) => { + const { id } = c.req.valid(VALIDATION_TARGET.PARAM); + return updateSequenceController(c, id, c.req.valid(VALIDATION_TARGET.JSON)); + }, + ) + .delete("/:id", validateRequest(VALIDATION_TARGET.PARAM, sequenceParamsSchema), (c) => { + const { id } = c.req.valid(VALIDATION_TARGET.PARAM); + return deleteSequenceController(c, id); + }) + .patch( + "/:id/steps/:stepId", + validateRequest(VALIDATION_TARGET.PARAM, sequenceStepParamsSchema), + validateRequest(VALIDATION_TARGET.JSON, updateSequenceStepSchema), + (c) => { + const { id, stepId } = c.req.valid(VALIDATION_TARGET.PARAM); + return updateSequenceStepController(c, id, stepId, c.req.valid(VALIDATION_TARGET.JSON)); + }, + ) + .post("/:id/publish", validateRequest(VALIDATION_TARGET.PARAM, sequenceParamsSchema), (c) => { + const { id } = c.req.valid(VALIDATION_TARGET.PARAM); + return publishSequenceController(c, id); + }) + .post("/:id/archive", validateRequest(VALIDATION_TARGET.PARAM, sequenceParamsSchema), (c) => { + const { id } = c.req.valid(VALIDATION_TARGET.PARAM); + return archiveSequenceController(c, id); + }) + .post( + "/:id/enrollments", + validateRequest(VALIDATION_TARGET.PARAM, sequenceParamsSchema), + validateRequest(VALIDATION_TARGET.JSON, enrollPeopleSchema), + (c) => { + const { id } = c.req.valid(VALIDATION_TARGET.PARAM); + return enrollPeopleController(c, id, c.req.valid(VALIDATION_TARGET.JSON)); + }, + ) + .get( + "/:id/enrollments", + validateRequest(VALIDATION_TARGET.PARAM, sequenceParamsSchema), + validateRequest(VALIDATION_TARGET.QUERY, listEnrollmentsQuerySchema), + (c) => { + const { id } = c.req.valid(VALIDATION_TARGET.PARAM); + return listSequenceEnrollmentsController(c, id, c.req.valid(VALIDATION_TARGET.QUERY)); + }, + ) + .get("/:id/activity", validateRequest(VALIDATION_TARGET.PARAM, sequenceParamsSchema), (c) => { + const { id } = c.req.valid(VALIDATION_TARGET.PARAM); + return listSequenceActivityController(c, id); + }); diff --git a/apps/api/src/services/sequence-adapters.ts b/apps/api/src/services/sequence-adapters.ts new file mode 100644 index 0000000..26e6f1a --- /dev/null +++ b/apps/api/src/services/sequence-adapters.ts @@ -0,0 +1,87 @@ +import { env } from "@/config/env.config.js"; + +export type SendSequenceEmailInput = { + accessToken: string; + fromEmail: string; + toEmail: string; + subject: string; + body: string; + unsubscribeUrl: string; +}; + +export type SendSequenceEmailResult = { + live: boolean; + gmailMessageId: string; + gmailThreadId: string; +}; + +export async function sendSequenceEmail(input: SendSequenceEmailInput): Promise { + if (!env.SEQUENCE_LIVE_SEND_ENABLED) { + const fingerprint = Buffer.from(`${input.fromEmail}:${input.toEmail}:${input.subject}`).toString( + "base64url", + ); + + return { + live: false, + gmailMessageId: `would-send-${fingerprint.slice(0, 32)}`, + gmailThreadId: `thread-${fingerprint.slice(0, 32)}`, + }; + } + + const rawMessage = [ + `From: ${input.fromEmail}`, + `To: ${input.toEmail}`, + `Subject: ${input.subject}`, + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=utf-8", + "", + `${input.body}\n\nUnsubscribe: ${input.unsubscribeUrl}`, + ].join("\r\n"); + + const response = await fetch("https://gmail.googleapis.com/gmail/v1/users/me/messages/send", { + method: "POST", + headers: { + authorization: `Bearer ${input.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ raw: Buffer.from(rawMessage).toString("base64url") }), + }); + + if (!response.ok) { + throw new Error(`Gmail send failed with status ${response.status}`); + } + + const body = (await response.json()) as { id?: string; threadId?: string }; + if (!body.id || !body.threadId) { + throw new Error("Gmail send response did not include message and thread identifiers"); + } + + return { + live: true, + gmailMessageId: body.id, + gmailThreadId: body.threadId, + }; +} + +export async function generateEmailContent(prompt: string) { + if (!env.GROQ_API_KEY) { + return { + subject: "Following up", + body: `Hi,\n\n${prompt.trim()}\n\nBest,`, + }; + } + + return { + subject: "Generated outreach", + body: `Hi,\n\n${prompt.trim()}\n\nBest,`, + }; +} + +export async function generateTaskCopy(input: { prompt?: string; personName: string; fallback: string }) { + const prompt = input.prompt?.trim(); + if (!prompt) { + return input.fallback; + } + + return `${prompt}\n\nContact: ${input.personName}`; +} diff --git a/apps/api/src/services/sequence-engine.ts b/apps/api/src/services/sequence-engine.ts new file mode 100644 index 0000000..3a5ff6f --- /dev/null +++ b/apps/api/src/services/sequence-engine.ts @@ -0,0 +1,192 @@ +import { randomUUID } from "node:crypto"; +import type { + SequenceDraftStepInput, + SequenceSendingWindow, + SequenceStepType, +} from "@workspace/validators/schemas/sequence"; +import { DEFAULT_SEQUENCE_SENDING_WINDOW } from "@workspace/validators/types/sequence"; +import { AppError } from "@/lib/app-error.js"; +import { STATUS_CODES } from "@/constants/status-codes.js"; +import type { SequenceStepSnapshot } from "@/db/schema/sequence.schema.js"; + +export const DEFAULT_SENDING_WINDOW: SequenceSendingWindow = { + weekdays: [...DEFAULT_SEQUENCE_SENDING_WINDOW.weekdays], + startHour: DEFAULT_SEQUENCE_SENDING_WINDOW.startHour, + endHour: DEFAULT_SEQUENCE_SENDING_WINDOW.endHour, +}; + +export function normalizeEmailAddress(email: string) { + return email.trim().toLowerCase(); +} + +function getConfigString(config: Record, key: string) { + const value = config[key]; + return typeof value === "string" ? value.trim() : ""; +} + +function getConfigNumber(config: Record, key: string, fallback: number) { + const value = config[key]; + if (typeof value === "number" && Number.isInteger(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isInteger(parsed)) return parsed; + } + + return fallback; +} + +export function validatePublishableSteps(steps: SequenceDraftStepInput[]) { + if (steps.length === 0) { + throw new AppError("Add at least one sequence step before publishing", STATUS_CODES.BAD_REQUEST); + } + + const positions = new Set(); + const sortedSteps = [...steps].sort((a, b) => a.position - b.position); + sortedSteps.forEach((step, index) => { + if (positions.has(step.position)) { + throw new AppError("Step positions must be unique", STATUS_CODES.BAD_REQUEST); + } + positions.add(step.position); + + if (step.position !== index) { + throw new AppError("Step positions must be linear starting at zero", STATUS_CODES.BAD_REQUEST); + } + + validateStepConfig(step.type, step.config); + }); + + return sortedSteps; +} + +export function validateStepConfig(type: SequenceStepType, config: Record) { + if (type === "email") { + if (!getConfigString(config, "subject") || !getConfigString(config, "body")) { + throw new AppError("Email steps require a saved subject and body", STATUS_CODES.BAD_REQUEST); + } + } + + if (type === "wait") { + const days = getConfigNumber(config, "days", 1); + if (days < 0 || days > 365) { + throw new AppError("Wait steps must be between 0 and 365 days", STATUS_CODES.BAD_REQUEST); + } + } +} + +export function toStepSnapshot(step: SequenceDraftStepInput): SequenceStepSnapshot { + return { + id: step.id ?? randomUUID(), + type: step.type, + name: step.name, + position: step.position, + config: normalizeStepConfig(step.type, step.config), + }; +} + +export function normalizeStepConfig(type: SequenceStepType, config: Record) { + if (type === "email") { + return { + prompt: getConfigString(config, "prompt"), + subject: getConfigString(config, "subject"), + body: getConfigString(config, "body"), + }; + } + + if (type === "wait") { + return { + days: getConfigNumber(config, "days", 1), + }; + } + + return { + prompt: getConfigString(config, "prompt"), + title: getConfigString(config, "title"), + body: getConfigString(config, "body"), + }; +} + +export function isEmailStep(step: SequenceStepSnapshot) { + return step.type === "email"; +} + +export function sequenceHasEmailStep(steps: SequenceStepSnapshot[]) { + return steps.some(isEmailStep); +} + +export function isWithinSendingWindow(date: Date, window: SequenceSendingWindow) { + const weekday = date.getUTCDay(); + const hour = date.getUTCHours(); + + return window.weekdays.includes(weekday) && hour >= window.startHour && hour < window.endHour; +} + +export function nextSendingWindowSlot(date: Date, window: SequenceSendingWindow) { + if (isWithinSendingWindow(date, window)) { + return date; + } + + const candidate = new Date(date); + candidate.setUTCMinutes(0, 0, 0); + + if (candidate.getUTCHours() >= window.endHour) { + candidate.setUTCDate(candidate.getUTCDate() + 1); + candidate.setUTCHours(window.startHour, 0, 0, 0); + } + + for (let offset = 0; offset <= 14; offset += 1) { + const weekday = candidate.getUTCDay(); + if (window.weekdays.includes(weekday)) { + if (candidate.getUTCHours() < window.startHour) { + candidate.setUTCHours(window.startHour, 0, 0, 0); + return candidate; + } + if (candidate.getUTCHours() >= window.startHour && candidate.getUTCHours() < window.endHour) { + return candidate; + } + } + + candidate.setUTCDate(candidate.getUTCDate() + 1); + candidate.setUTCHours(window.startHour, 0, 0, 0); + } + + throw new AppError("No valid sending window could be calculated", STATUS_CODES.BAD_REQUEST); +} + +export function firstStepDueAt(steps: SequenceStepSnapshot[], now: Date, window: SequenceSendingWindow) { + const firstStep = steps[0]; + if (!firstStep) return null; + + return firstStep.type === "email" ? nextSendingWindowSlot(now, window) : now; +} + +export function nextDueAtAfterStep( + steps: SequenceStepSnapshot[], + completedPosition: number, + completedAt: Date, + window: SequenceSendingWindow, +) { + const completedStep = steps.find((step) => step.position === completedPosition); + const nextStep = steps.find((step) => step.position === completedPosition + 1); + if (!completedStep || !nextStep) { + return null; + } + + const baseDate = new Date(completedAt); + if (completedStep.type === "wait") { + const days = getConfigNumber(completedStep.config, "days", 1); + baseDate.setUTCDate(baseDate.getUTCDate() + days); + } + + return nextStep.type === "email" ? nextSendingWindowSlot(baseDate, window) : baseDate; +} + +export function readableStepType(type: SequenceStepType) { + const labels: Record = { + email: "Email", + wait: "Wait", + linkedin_task: "LinkedIn task", + general_task: "General task", + }; + + return labels[type]; +} diff --git a/apps/api/src/services/sequence.service.ts b/apps/api/src/services/sequence.service.ts new file mode 100644 index 0000000..80b985d --- /dev/null +++ b/apps/api/src/services/sequence.service.ts @@ -0,0 +1,1458 @@ +import { randomUUID } from "node:crypto"; +import { and, asc, count, desc, eq, gte, ilike, inArray, lte, or, sql } from "drizzle-orm"; +import type { + ConnectGmailInput, + CreateSequenceInput, + EnrollPeopleInput, + ListEnrollmentsQuery, + ListSequencesQuery, + SequenceDraftStepInput, + SequenceSendingWindow, + UpdateSequenceInput, + UpdateSequenceStepInput, +} from "@workspace/validators/schemas/sequence"; +import { db } from "@/db/client.js"; +import { + gmailIntegrations, + people, + sequenceActivityEvents, + sequenceEnrollments, + sequenceEnrollmentSteps, + sequences, + sequenceSteps, + sequenceSuppressions, + sequenceTasks, + sequenceUnsubscribeTokens, + sequenceVersions, +} from "@/db/schema/index.js"; +import { STATUS_CODES } from "@/constants/status-codes.js"; +import { env } from "@/config/env.config.js"; +import { AppError } from "@/lib/app-error.js"; +import { + createPublicToken, + decryptSecretToken, + encryptSecretToken, + hashPublicToken, +} from "@/lib/token-crypto.js"; +import { + DEFAULT_SENDING_WINDOW, + firstStepDueAt, + isWithinSendingWindow, + nextDueAtAfterStep, + nextSendingWindowSlot, + normalizeEmailAddress, + normalizeStepConfig, + readableStepType, + sequenceHasEmailStep, + toStepSnapshot, + validatePublishableSteps, +} from "./sequence-engine.js"; +import { generateEmailContent, generateTaskCopy, sendSequenceEmail } from "./sequence-adapters.js"; + +const GMAIL_SEND_SCOPE = "https://www.googleapis.com/auth/gmail.send"; +const GMAIL_METADATA_SCOPE = "https://www.googleapis.com/auth/gmail.metadata"; + +type SequenceActivityType = typeof sequenceActivityEvents.$inferInsert.type; + +type ActivityInput = { + workspaceId: string; + sequenceId?: string | null; + enrollmentId?: string | null; + personId?: string | null; + type: SequenceActivityType; + message: string; + metadata?: Record; +}; + +function defaultSendingWindow(input?: SequenceSendingWindow) { + return input ?? DEFAULT_SENDING_WINDOW; +} + +function tokenSecret() { + return env.BETTER_AUTH_SECRET; +} + +function startOfUtcDay(date: Date) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +function startOfUtcHour(date: Date) { + return new Date( + Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate(), + date.getUTCHours(), + ), + ); +} + +async function recordActivity(input: ActivityInput) { + const [event] = await db + .insert(sequenceActivityEvents) + .values({ + workspaceId: input.workspaceId, + sequenceId: input.sequenceId, + enrollmentId: input.enrollmentId, + personId: input.personId, + type: input.type, + message: input.message, + metadata: input.metadata ?? {}, + }) + .returning(); + + return event; +} + +async function getSequenceOrThrow(workspaceId: string, sequenceId: string) { + const sequence = await db.query.sequences.findFirst({ + where: and(eq(sequences.workspaceId, workspaceId), eq(sequences.id, sequenceId)), + with: { + steps: { + orderBy: (table, { asc: orderAsc }) => [orderAsc(table.position)], + }, + }, + }); + + if (!sequence) { + throw new AppError("Sequence not found", STATUS_CODES.NOT_FOUND); + } + + return sequence; +} + +async function getLatestPublishedVersionOrThrow(workspaceId: string, sequenceId: string) { + const sequence = await db.query.sequences.findFirst({ + where: and(eq(sequences.workspaceId, workspaceId), eq(sequences.id, sequenceId)), + }); + + if (!sequence || !sequence.latestPublishedVersionId) { + throw new AppError("Publish the sequence before enrolling people", STATUS_CODES.BAD_REQUEST); + } + + if (sequence.status === "archived") { + throw new AppError("Archived sequences cannot accept new enrollments", STATUS_CODES.BAD_REQUEST); + } + + const version = await db.query.sequenceVersions.findFirst({ + where: and( + eq(sequenceVersions.workspaceId, workspaceId), + eq(sequenceVersions.id, sequence.latestPublishedVersionId), + ), + }); + + if (!version) { + throw new AppError("Published sequence version not found", STATUS_CODES.NOT_FOUND); + } + + return { sequence, version }; +} + +function toDraftStepInput(step: typeof sequenceSteps.$inferSelect): SequenceDraftStepInput { + return { + id: step.id, + type: step.type, + name: step.name, + position: step.position, + config: step.config, + }; +} + +async function replaceDraftSteps( + workspaceId: string, + sequenceId: string, + stepInputs: SequenceDraftStepInput[], +) { + const normalizedSteps = stepInputs + .map((step) => ({ + ...step, + id: step.id ?? randomUUID(), + config: normalizeStepConfig(step.type, step.config), + })) + .sort((a, b) => a.position - b.position); + + await db.transaction(async (tx) => { + await tx.delete(sequenceSteps).where(eq(sequenceSteps.sequenceId, sequenceId)); + if (normalizedSteps.length > 0) { + await tx.insert(sequenceSteps).values( + normalizedSteps.map((step) => ({ + id: step.id, + workspaceId, + sequenceId, + type: step.type, + name: step.name, + position: step.position, + config: step.config, + })), + ); + } + }); +} + +export async function listSequences(workspaceId: string, query: ListSequencesQuery) { + const { page, pageSize, search, status } = query; + const offset = (page - 1) * pageSize; + const conditions = [eq(sequences.workspaceId, workspaceId)]; + if (status) conditions.push(eq(sequences.status, status)); + if (search) conditions.push(ilike(sequences.name, `%${search}%`)); + + const whereClause = and(...conditions); + const [rows, totalCountResult] = await Promise.all([ + db + .select() + .from(sequences) + .where(whereClause) + .orderBy(desc(sequences.updatedAt)) + .limit(pageSize) + .offset(offset), + db.select({ totalCount: count() }).from(sequences).where(whereClause), + ]); + + const sequenceIds = rows.map((sequence) => sequence.id); + const [enrollmentRows, sentRows, replyRows, failureRows] = + sequenceIds.length === 0 + ? [[], [], [], []] + : await Promise.all([ + db + .select({ sequenceId: sequenceEnrollments.sequenceId, total: count() }) + .from(sequenceEnrollments) + .where(inArray(sequenceEnrollments.sequenceId, sequenceIds)) + .groupBy(sequenceEnrollments.sequenceId), + db + .select({ sequenceId: sequenceActivityEvents.sequenceId, total: count() }) + .from(sequenceActivityEvents) + .where( + and( + inArray(sequenceActivityEvents.sequenceId, sequenceIds), + inArray(sequenceActivityEvents.type, ["email_sent", "email_would_send"]), + ), + ) + .groupBy(sequenceActivityEvents.sequenceId), + db + .select({ sequenceId: sequenceActivityEvents.sequenceId, total: count() }) + .from(sequenceActivityEvents) + .where( + and( + inArray(sequenceActivityEvents.sequenceId, sequenceIds), + eq(sequenceActivityEvents.type, "reply_detected"), + ), + ) + .groupBy(sequenceActivityEvents.sequenceId), + db + .select({ sequenceId: sequenceEnrollments.sequenceId, total: count() }) + .from(sequenceEnrollmentSteps) + .innerJoin( + sequenceEnrollments, + eq(sequenceEnrollmentSteps.enrollmentId, sequenceEnrollments.id), + ) + .where( + and( + inArray(sequenceEnrollments.sequenceId, sequenceIds), + eq(sequenceEnrollmentSteps.status, "failed"), + ), + ) + .groupBy(sequenceEnrollments.sequenceId), + ]); + + const countBySequence = (items: Array<{ sequenceId: string | null; total: number }>) => + new Map(items.filter((item) => item.sequenceId).map((item) => [item.sequenceId!, Number(item.total)])); + + const enrollments = countBySequence(enrollmentRows); + const sent = countBySequence(sentRows); + const replies = countBySequence(replyRows); + const failures = countBySequence(failureRows); + const totalCount = Number(totalCountResult[0]?.totalCount ?? 0); + + return { + sequences: rows.map((sequence) => ({ + ...sequence, + metrics: { + enrollments: enrollments.get(sequence.id) ?? 0, + sent: sent.get(sequence.id) ?? 0, + replies: replies.get(sequence.id) ?? 0, + failures: failures.get(sequence.id) ?? 0, + }, + })), + meta: { + page, + pageSize, + totalCount, + totalPages: totalCount === 0 ? 0 : Math.ceil(totalCount / pageSize), + }, + }; +} + +export async function createSequence( + workspaceId: string, + userId: string, + input: CreateSequenceInput, +) { + const [sequence] = await db + .insert(sequences) + .values({ + workspaceId, + createdById: userId, + name: input.name, + timezone: input.timezone, + sendingWindow: defaultSendingWindow(input.sendingWindow), + }) + .returning(); + + if (!sequence) throw new AppError("Unable to create sequence"); + await recordActivity({ + workspaceId, + sequenceId: sequence.id, + type: "sequence_created", + message: `Sequence "${sequence.name}" was created.`, + }); + + return sequence; +} + +export async function getSequence(workspaceId: string, sequenceId: string) { + const sequence = await getSequenceOrThrow(workspaceId, sequenceId); + const [enrollmentsResult, activityResult] = await Promise.all([ + db + .select({ totalCount: count() }) + .from(sequenceEnrollments) + .where(eq(sequenceEnrollments.sequenceId, sequenceId)), + db + .select({ totalCount: count() }) + .from(sequenceActivityEvents) + .where(eq(sequenceActivityEvents.sequenceId, sequenceId)), + ]); + + return { + sequence, + counts: { + enrollments: Number(enrollmentsResult[0]?.totalCount ?? 0), + activity: Number(activityResult[0]?.totalCount ?? 0), + }, + }; +} + +export async function updateSequence( + workspaceId: string, + sequenceId: string, + input: UpdateSequenceInput, +) { + const existing = await getSequenceOrThrow(workspaceId, sequenceId); + if (existing.status === "archived") { + throw new AppError("Archived sequences cannot be edited", STATUS_CODES.BAD_REQUEST); + } + + await db + .update(sequences) + .set({ + name: input.name, + timezone: input.timezone, + sendingWindow: input.sendingWindow, + updatedAt: new Date(), + }) + .where(and(eq(sequences.workspaceId, workspaceId), eq(sequences.id, sequenceId))); + + if (input.steps) { + await replaceDraftSteps(workspaceId, sequenceId, input.steps); + } + + await recordActivity({ + workspaceId, + sequenceId, + type: "sequence_updated", + message: `Sequence "${input.name ?? existing.name}" was updated.`, + }); + + return getSequence(workspaceId, sequenceId); +} + +export async function updateSequenceStep( + workspaceId: string, + sequenceId: string, + stepId: string, + input: UpdateSequenceStepInput, +) { + const step = await db.query.sequenceSteps.findFirst({ + where: and( + eq(sequenceSteps.workspaceId, workspaceId), + eq(sequenceSteps.sequenceId, sequenceId), + eq(sequenceSteps.id, stepId), + ), + }); + + if (!step) { + throw new AppError("Sequence step not found", STATUS_CODES.NOT_FOUND); + } + + const [updatedStep] = await db + .update(sequenceSteps) + .set({ + name: input.name, + config: input.config ? normalizeStepConfig(step.type, input.config) : undefined, + updatedAt: new Date(), + }) + .where(eq(sequenceSteps.id, stepId)) + .returning(); + + return updatedStep; +} + +export async function publishSequence(workspaceId: string, sequenceId: string, userId: string) { + const sequence = await getSequenceOrThrow(workspaceId, sequenceId); + if (sequence.status === "archived") { + throw new AppError("Archived sequences cannot be published", STATUS_CODES.BAD_REQUEST); + } + + const sortedSteps = validatePublishableSteps(sequence.steps.map(toDraftStepInput)); + const stepSnapshots = sortedSteps.map(toStepSnapshot); + const [latestVersion] = await db + .select({ versionNumber: sequenceVersions.versionNumber }) + .from(sequenceVersions) + .where(eq(sequenceVersions.sequenceId, sequenceId)) + .orderBy(desc(sequenceVersions.versionNumber)) + .limit(1); + const versionNumber = (latestVersion?.versionNumber ?? 0) + 1; + + const version = await db.transaction(async (tx) => { + const [createdVersion] = await tx + .insert(sequenceVersions) + .values({ + workspaceId, + sequenceId, + versionNumber, + name: sequence.name, + stepsSnapshot: stepSnapshots, + publishedById: userId, + }) + .returning(); + + if (!createdVersion) throw new AppError("Unable to publish sequence"); + + await tx + .update(sequences) + .set({ + status: "published", + latestPublishedVersionId: createdVersion.id, + updatedAt: new Date(), + }) + .where(eq(sequences.id, sequenceId)); + + return createdVersion; + }); + + await recordActivity({ + workspaceId, + sequenceId, + type: "sequence_published", + message: `Version ${version.versionNumber} was published.`, + metadata: { versionId: version.id }, + }); + + return version; +} + +export async function archiveSequence(workspaceId: string, sequenceId: string) { + const [sequence] = await db + .update(sequences) + .set({ status: "archived", updatedAt: new Date() }) + .where(and(eq(sequences.workspaceId, workspaceId), eq(sequences.id, sequenceId))) + .returning(); + + if (!sequence) { + throw new AppError("Sequence not found", STATUS_CODES.NOT_FOUND); + } + + await recordActivity({ + workspaceId, + sequenceId, + type: "sequence_archived", + message: `Sequence "${sequence.name}" was archived.`, + }); + + return sequence; +} + +export async function deleteSequence(workspaceId: string, sequenceId: string) { + const sequence = await getSequenceOrThrow(workspaceId, sequenceId); + if (sequence.status !== "draft") { + throw new AppError("Only draft sequences can be deleted", STATUS_CODES.BAD_REQUEST); + } + + const [enrollmentCount, eventCount] = await Promise.all([ + db + .select({ totalCount: count() }) + .from(sequenceEnrollments) + .where(eq(sequenceEnrollments.sequenceId, sequenceId)), + db + .select({ totalCount: count() }) + .from(sequenceActivityEvents) + .where( + and( + eq(sequenceActivityEvents.sequenceId, sequenceId), + or( + eq(sequenceActivityEvents.type, "enrollment_started"), + eq(sequenceActivityEvents.type, "email_sent"), + eq(sequenceActivityEvents.type, "email_would_send"), + eq(sequenceActivityEvents.type, "reply_detected"), + eq(sequenceActivityEvents.type, "unsubscribed"), + ), + ), + ), + ]); + + if (Number(enrollmentCount[0]?.totalCount ?? 0) > 0 || Number(eventCount[0]?.totalCount ?? 0) > 0) { + throw new AppError("Sequence history must be archived, not deleted", STATUS_CODES.BAD_REQUEST); + } + + const [deletedSequence] = await db + .delete(sequences) + .where(and(eq(sequences.workspaceId, workspaceId), eq(sequences.id, sequenceId))) + .returning(); + + return deletedSequence; +} + +export async function enrollPeople( + workspaceId: string, + sequenceId: string, + userId: string, + input: EnrollPeopleInput, + now = new Date(), +) { + const { sequence, version } = await getLatestPublishedVersionOrThrow(workspaceId, sequenceId); + const hasEmailStep = sequenceHasEmailStep(version.stepsSnapshot); + let gmailIntegration: typeof gmailIntegrations.$inferSelect | undefined; + + if (input.gmailIntegrationId) { + gmailIntegration = + (await db.query.gmailIntegrations.findFirst({ + where: and( + eq(gmailIntegrations.workspaceId, workspaceId), + eq(gmailIntegrations.userId, userId), + eq(gmailIntegrations.id, input.gmailIntegrationId), + ), + })) ?? undefined; + } + + if (hasEmailStep && !gmailIntegration) { + throw new AppError("Choose a connected Gmail sender for email sequences", STATUS_CODES.BAD_REQUEST); + } + + const selectedPeople = await db + .select() + .from(people) + .where(and(eq(people.workspaceId, workspaceId), inArray(people.id, input.personIds))); + if (selectedPeople.length !== input.personIds.length) { + throw new AppError("One or more people were not found", STATUS_CODES.BAD_REQUEST); + } + + const window = gmailIntegration?.sendingWindow ?? sequence.sendingWindow; + const firstDue = firstStepDueAt(version.stepsSnapshot, now, window); + + const createdEnrollments = await db.transaction(async (tx) => { + const enrollments: Array = []; + for (const person of selectedPeople) { + const [enrollment] = await tx + .insert(sequenceEnrollments) + .values({ + workspaceId, + sequenceId, + versionId: version.id, + personId: person.id, + gmailIntegrationId: gmailIntegration?.id, + enrolledById: userId, + currentPosition: 0, + nextStepDueAt: firstDue, + }) + .returning(); + if (!enrollment) continue; + + await tx.insert(sequenceEnrollmentSteps).values( + version.stepsSnapshot.map((step) => ({ + workspaceId, + enrollmentId: enrollment.id, + stepId: step.id, + type: step.type, + name: step.name, + position: step.position, + stepSnapshot: step, + dueAt: step.position === 0 ? firstDue : null, + })), + ); + enrollments.push(enrollment); + } + + return enrollments; + }); + + await Promise.all( + createdEnrollments.map((enrollment) => + recordActivity({ + workspaceId, + sequenceId, + enrollmentId: enrollment.id, + personId: enrollment.personId, + type: "enrollment_started", + message: `Enrollment started for sequence "${sequence.name}".`, + metadata: { versionId: version.id }, + }), + ), + ); + + return createdEnrollments; +} + +export async function listSequenceEnrollments( + workspaceId: string, + sequenceId: string, + query: ListEnrollmentsQuery, +) { + const conditions = [ + eq(sequenceEnrollments.workspaceId, workspaceId), + eq(sequenceEnrollments.sequenceId, sequenceId), + ]; + if (query.status) conditions.push(eq(sequenceEnrollments.status, query.status)); + + return db.query.sequenceEnrollments.findMany({ + where: and(...conditions), + with: { + person: { + columns: { + id: true, + name: true, + email: true, + linkedinUrl: true, + }, + }, + gmailIntegration: { + columns: { + id: true, + email: true, + status: true, + }, + }, + }, + orderBy: (table, { desc: orderDesc }) => [orderDesc(table.createdAt)], + }); +} + +export async function listSequenceActivity(workspaceId: string, sequenceId: string) { + return db.query.sequenceActivityEvents.findMany({ + where: and( + eq(sequenceActivityEvents.workspaceId, workspaceId), + eq(sequenceActivityEvents.sequenceId, sequenceId), + ), + orderBy: (table, { desc: orderDesc }) => [orderDesc(table.createdAt)], + limit: 100, + }); +} + +export async function pauseEnrollment(workspaceId: string, enrollmentId: string) { + const [enrollment] = await db + .update(sequenceEnrollments) + .set({ status: "paused", pausedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(sequenceEnrollments.workspaceId, workspaceId), eq(sequenceEnrollments.id, enrollmentId))) + .returning(); + + if (!enrollment) throw new AppError("Enrollment not found", STATUS_CODES.NOT_FOUND); + await recordActivity({ + workspaceId, + sequenceId: enrollment.sequenceId, + enrollmentId, + personId: enrollment.personId, + type: "paused", + message: "Enrollment was paused.", + }); + + return enrollment; +} + +export async function resumeEnrollment(workspaceId: string, enrollmentId: string) { + const [enrollment] = await db + .update(sequenceEnrollments) + .set({ status: "active", pausedAt: null, updatedAt: new Date() }) + .where(and(eq(sequenceEnrollments.workspaceId, workspaceId), eq(sequenceEnrollments.id, enrollmentId))) + .returning(); + + if (!enrollment) throw new AppError("Enrollment not found", STATUS_CODES.NOT_FOUND); + await recordActivity({ + workspaceId, + sequenceId: enrollment.sequenceId, + enrollmentId, + personId: enrollment.personId, + type: "resumed", + message: "Enrollment was resumed.", + }); + + return enrollment; +} + +async function failEnrollmentStep(input: { + workspaceId: string; + step: typeof sequenceEnrollmentSteps.$inferSelect; + enrollment: typeof sequenceEnrollments.$inferSelect; + message: string; + taskTitle: string; + now: Date; + status?: "failed" | "unsubscribed" | "paused"; +}) { + await db.transaction(async (tx) => { + await tx + .update(sequenceEnrollmentSteps) + .set({ + status: "failed", + lastError: input.message, + processedAt: input.now, + updatedAt: input.now, + }) + .where(eq(sequenceEnrollmentSteps.id, input.step.id)); + await tx + .update(sequenceEnrollments) + .set({ + status: input.status ?? "failed", + lastError: input.message, + nextStepDueAt: null, + updatedAt: input.now, + }) + .where(eq(sequenceEnrollments.id, input.enrollment.id)); + await tx.insert(sequenceTasks).values({ + workspaceId: input.workspaceId, + sequenceId: input.enrollment.sequenceId, + enrollmentId: input.enrollment.id, + enrollmentStepId: input.step.id, + personId: input.enrollment.personId, + assignedToId: input.enrollment.enrolledById, + type: input.status === "paused" ? "gmail_warning" : "failure", + title: input.taskTitle, + body: input.message, + dueAt: input.now, + }); + }); + + await recordActivity({ + workspaceId: input.workspaceId, + sequenceId: input.enrollment.sequenceId, + enrollmentId: input.enrollment.id, + personId: input.enrollment.personId, + type: input.status === "paused" ? "gmail_warning" : "email_failed", + message: input.message, + }); +} + +async function advanceEnrollment( + enrollment: typeof sequenceEnrollments.$inferSelect, + completedStep: typeof sequenceEnrollmentSteps.$inferSelect, + now: Date, + window: SequenceSendingWindow, +) { + const steps = await db.query.sequenceEnrollmentSteps.findMany({ + where: eq(sequenceEnrollmentSteps.enrollmentId, enrollment.id), + orderBy: (table, { asc: orderAsc }) => [orderAsc(table.position)], + }); + const snapshots = steps.map((step) => step.stepSnapshot); + const nextDue = nextDueAtAfterStep(snapshots, completedStep.position, now, window); + const nextStep = steps.find((step) => step.position === completedStep.position + 1); + + if (!nextDue || !nextStep) { + await db + .update(sequenceEnrollments) + .set({ + status: "completed", + completedAt: now, + nextStepDueAt: null, + updatedAt: now, + }) + .where(eq(sequenceEnrollments.id, enrollment.id)); + await recordActivity({ + workspaceId: enrollment.workspaceId, + sequenceId: enrollment.sequenceId, + enrollmentId: enrollment.id, + personId: enrollment.personId, + type: "completed", + message: "Enrollment completed.", + }); + return; + } + + await db.transaction(async (tx) => { + await tx + .update(sequenceEnrollmentSteps) + .set({ dueAt: nextDue, status: "pending", updatedAt: now }) + .where(eq(sequenceEnrollmentSteps.id, nextStep.id)); + await tx + .update(sequenceEnrollments) + .set({ + currentPosition: nextStep.position, + nextStepDueAt: nextDue, + updatedAt: now, + }) + .where(eq(sequenceEnrollments.id, enrollment.id)); + }); +} + +async function countSentForGmail(workspaceId: string, gmailIntegrationId: string, since: Date) { + const [result] = await db + .select({ totalCount: count() }) + .from(sequenceActivityEvents) + .where( + and( + eq(sequenceActivityEvents.workspaceId, workspaceId), + inArray(sequenceActivityEvents.type, ["email_sent", "email_would_send"]), + gte(sequenceActivityEvents.createdAt, since), + sql`${sequenceActivityEvents.metadata}->>'gmailIntegrationId' = ${gmailIntegrationId}`, + ), + ); + + return Number(result?.totalCount ?? 0); +} + +async function createUnsubscribeUrl(input: { + workspaceId: string; + sequenceId: string; + enrollmentId: string; + personId: string; + email: string; +}) { + const token = createPublicToken(); + await db.insert(sequenceUnsubscribeTokens).values({ + workspaceId: input.workspaceId, + sequenceId: input.sequenceId, + enrollmentId: input.enrollmentId, + personId: input.personId, + email: normalizeEmailAddress(input.email), + tokenHash: hashPublicToken(token), + }); + + return `${env.WEB_URL}/unsubscribe/${encodeURIComponent(token)}`; +} + +export async function executeDueSequenceWork(now = new Date(), limit = 25) { + const dueRows = await db + .select({ + step: sequenceEnrollmentSteps, + enrollment: sequenceEnrollments, + person: people, + sequence: sequences, + gmailIntegration: gmailIntegrations, + }) + .from(sequenceEnrollmentSteps) + .innerJoin(sequenceEnrollments, eq(sequenceEnrollmentSteps.enrollmentId, sequenceEnrollments.id)) + .innerJoin(people, eq(sequenceEnrollments.personId, people.id)) + .innerJoin(sequences, eq(sequenceEnrollments.sequenceId, sequences.id)) + .leftJoin(gmailIntegrations, eq(sequenceEnrollments.gmailIntegrationId, gmailIntegrations.id)) + .where( + and( + eq(sequenceEnrollmentSteps.status, "pending"), + lte(sequenceEnrollmentSteps.dueAt, now), + eq(sequenceEnrollments.status, "active"), + eq(sequences.status, "published"), + ), + ) + .orderBy(asc(sequenceEnrollmentSteps.dueAt)) + .limit(limit); + + const results = []; + for (const row of dueRows) { + results.push(await executeEnrollmentStep(row, now)); + } + + return results; +} + +async function executeEnrollmentStep( + row: { + step: typeof sequenceEnrollmentSteps.$inferSelect; + enrollment: typeof sequenceEnrollments.$inferSelect; + person: typeof people.$inferSelect; + sequence: typeof sequences.$inferSelect; + gmailIntegration: typeof gmailIntegrations.$inferSelect | null; + }, + now: Date, +) { + const { step, enrollment, person, sequence, gmailIntegration } = row; + const [claimedStep] = await db + .update(sequenceEnrollmentSteps) + .set({ + status: "processing", + attempts: step.attempts + 1, + updatedAt: now, + }) + .where(and(eq(sequenceEnrollmentSteps.id, step.id), eq(sequenceEnrollmentSteps.status, "pending"))) + .returning(); + + if (!claimedStep) { + return { stepId: step.id, status: "skipped" }; + } + + const window = gmailIntegration?.sendingWindow ?? sequence.sendingWindow; + + if (claimedStep.type === "wait") { + await db + .update(sequenceEnrollmentSteps) + .set({ status: "completed", processedAt: now, updatedAt: now }) + .where(eq(sequenceEnrollmentSteps.id, claimedStep.id)); + await advanceEnrollment(enrollment, claimedStep, now, window); + return { stepId: claimedStep.id, status: "completed" }; + } + + if (claimedStep.type === "general_task" || claimedStep.type === "linkedin_task") { + const config = claimedStep.stepSnapshot.config; + const fallbackTitle = + typeof config.title === "string" && config.title.trim() + ? config.title.trim() + : `${readableStepType(claimedStep.type)} for ${person.name}`; + const taskBody = await generateTaskCopy({ + prompt: typeof config.prompt === "string" ? config.prompt : undefined, + personName: person.name, + fallback: + typeof config.body === "string" && config.body.trim() + ? config.body.trim() + : `Complete ${readableStepType(claimedStep.type).toLowerCase()} for ${person.name}.`, + }); + const [task] = await db + .insert(sequenceTasks) + .values({ + workspaceId: enrollment.workspaceId, + sequenceId: enrollment.sequenceId, + enrollmentId: enrollment.id, + enrollmentStepId: claimedStep.id, + personId: person.id, + assignedToId: enrollment.enrolledById, + type: claimedStep.type, + title: fallbackTitle, + body: taskBody, + dueAt: now, + }) + .returning(); + + await db.transaction(async (tx) => { + await tx + .update(sequenceEnrollmentSteps) + .set({ createdTaskId: task?.id, status: "processing", updatedAt: now }) + .where(eq(sequenceEnrollmentSteps.id, claimedStep.id)); + await tx + .update(sequenceEnrollments) + .set({ nextStepDueAt: null, updatedAt: now }) + .where(eq(sequenceEnrollments.id, enrollment.id)); + }); + + await recordActivity({ + workspaceId: enrollment.workspaceId, + sequenceId: enrollment.sequenceId, + enrollmentId: enrollment.id, + personId: person.id, + type: "task_created", + message: `${readableStepType(claimedStep.type)} was created.`, + metadata: { taskId: task?.id }, + }); + return { stepId: claimedStep.id, status: "task_created" }; + } + + if (!person.email) { + await failEnrollmentStep({ + workspaceId: enrollment.workspaceId, + step: claimedStep, + enrollment, + message: `${person.name} does not have an email address.`, + taskTitle: "Add recipient email before continuing sequence", + now, + }); + return { stepId: claimedStep.id, status: "failed" }; + } + + const normalizedEmail = normalizeEmailAddress(person.email); + const suppression = await db.query.sequenceSuppressions.findFirst({ + where: and( + eq(sequenceSuppressions.workspaceId, enrollment.workspaceId), + eq(sequenceSuppressions.email, normalizedEmail), + ), + }); + if (suppression) { + await failEnrollmentStep({ + workspaceId: enrollment.workspaceId, + step: claimedStep, + enrollment, + message: `${normalizedEmail} is suppressed for this workspace.`, + taskTitle: "Recipient has unsubscribed", + now, + status: "unsubscribed", + }); + return { stepId: claimedStep.id, status: "unsubscribed" }; + } + + if (!gmailIntegration || gmailIntegration.status !== "connected") { + await failEnrollmentStep({ + workspaceId: enrollment.workspaceId, + step: claimedStep, + enrollment, + message: "Reconnect Gmail before this sequence can continue.", + taskTitle: "Reconnect Gmail sender", + now, + status: "paused", + }); + return { stepId: claimedStep.id, status: "paused" }; + } + + if ( + !gmailIntegration.grantedScopes.includes(GMAIL_SEND_SCOPE) || + !gmailIntegration.grantedScopes.includes(GMAIL_METADATA_SCOPE) + ) { + await db + .update(gmailIntegrations) + .set({ status: "reconnect_required", updatedAt: now }) + .where(eq(gmailIntegrations.id, gmailIntegration.id)); + await failEnrollmentStep({ + workspaceId: enrollment.workspaceId, + step: claimedStep, + enrollment, + message: "Gmail needs send and metadata scopes before automation can continue.", + taskTitle: "Reconnect Gmail permissions", + now, + status: "paused", + }); + return { stepId: claimedStep.id, status: "paused" }; + } + + if (!isWithinSendingWindow(now, window)) { + const nextDue = nextSendingWindowSlot(now, window); + await db.transaction(async (tx) => { + await tx + .update(sequenceEnrollmentSteps) + .set({ status: "pending", dueAt: nextDue, updatedAt: now }) + .where(eq(sequenceEnrollmentSteps.id, claimedStep.id)); + await tx + .update(sequenceEnrollments) + .set({ nextStepDueAt: nextDue, updatedAt: now }) + .where(eq(sequenceEnrollments.id, enrollment.id)); + }); + return { stepId: claimedStep.id, status: "rescheduled", dueAt: nextDue }; + } + + const sentToday = await countSentForGmail( + enrollment.workspaceId, + gmailIntegration.id, + startOfUtcDay(now), + ); + const sentThisHour = await countSentForGmail( + enrollment.workspaceId, + gmailIntegration.id, + startOfUtcHour(now), + ); + if (sentToday >= gmailIntegration.dailyLimit || sentThisHour >= gmailIntegration.hourlyLimit) { + const nextDue = new Date(now); + nextDue.setUTCHours(nextDue.getUTCHours() + 1, 0, 0, 0); + await db + .update(sequenceEnrollmentSteps) + .set({ status: "pending", dueAt: nextSendingWindowSlot(nextDue, window), updatedAt: now }) + .where(eq(sequenceEnrollmentSteps.id, claimedStep.id)); + return { stepId: claimedStep.id, status: "rate_limited" }; + } + + const config = claimedStep.stepSnapshot.config; + const subject = typeof config.subject === "string" ? config.subject : ""; + const body = typeof config.body === "string" ? config.body : ""; + if (!subject.trim() || !body.trim()) { + await failEnrollmentStep({ + workspaceId: enrollment.workspaceId, + step: claimedStep, + enrollment, + message: "Email step is missing saved subject or body.", + taskTitle: "Fix sequence email content", + now, + }); + return { stepId: claimedStep.id, status: "failed" }; + } + + const unsubscribeUrl = await createUnsubscribeUrl({ + workspaceId: enrollment.workspaceId, + sequenceId: enrollment.sequenceId, + enrollmentId: enrollment.id, + personId: person.id, + email: normalizedEmail, + }); + const sendResult = await sendSequenceEmail({ + accessToken: decryptSecretToken(gmailIntegration.accessTokenEncrypted, tokenSecret()), + fromEmail: gmailIntegration.email, + toEmail: normalizedEmail, + subject, + body, + unsubscribeUrl, + }); + const activityType = sendResult.live ? "email_sent" : "email_would_send"; + + await db.transaction(async (tx) => { + await tx + .update(sequenceEnrollmentSteps) + .set({ + status: "completed", + gmailMessageId: sendResult.gmailMessageId, + gmailThreadId: sendResult.gmailThreadId, + processedAt: now, + updatedAt: now, + }) + .where(eq(sequenceEnrollmentSteps.id, claimedStep.id)); + await tx + .update(people) + .set({ lastContactedAt: now, updatedAt: now }) + .where(eq(people.id, person.id)); + }); + await recordActivity({ + workspaceId: enrollment.workspaceId, + sequenceId: enrollment.sequenceId, + enrollmentId: enrollment.id, + personId: person.id, + type: activityType, + message: sendResult.live ? "Sequence email sent." : "Sequence email would send.", + metadata: { + gmailIntegrationId: gmailIntegration.id, + gmailThreadId: sendResult.gmailThreadId, + gmailMessageId: sendResult.gmailMessageId, + unsubscribeUrl, + }, + }); + await advanceEnrollment(enrollment, claimedStep, now, window); + + return { stepId: claimedStep.id, status: activityType }; +} + +export async function completeSequenceTask(workspaceId: string, taskId: string, now = new Date()) { + const task = await db.query.sequenceTasks.findFirst({ + where: and(eq(sequenceTasks.workspaceId, workspaceId), eq(sequenceTasks.id, taskId)), + }); + + if (!task) throw new AppError("Sequence task not found", STATUS_CODES.NOT_FOUND); + if (task.status !== "open") return task; + + const [updatedTask] = await db + .update(sequenceTasks) + .set({ status: "completed", completedAt: now, updatedAt: now }) + .where(eq(sequenceTasks.id, taskId)) + .returning(); + + if (task.enrollmentId && task.enrollmentStepId) { + const enrollment = await db.query.sequenceEnrollments.findFirst({ + where: eq(sequenceEnrollments.id, task.enrollmentId), + with: { gmailIntegration: true, sequence: true }, + }); + const step = await db.query.sequenceEnrollmentSteps.findFirst({ + where: eq(sequenceEnrollmentSteps.id, task.enrollmentStepId), + }); + if (enrollment && step) { + await db + .update(sequenceEnrollmentSteps) + .set({ status: "completed", processedAt: now, updatedAt: now }) + .where(eq(sequenceEnrollmentSteps.id, step.id)); + await advanceEnrollment( + enrollment, + step, + now, + enrollment.gmailIntegration?.sendingWindow ?? enrollment.sequence.sendingWindow, + ); + } + } + + await recordActivity({ + workspaceId, + sequenceId: task.sequenceId, + enrollmentId: task.enrollmentId, + personId: task.personId, + type: "task_completed", + message: `Task "${task.title}" was completed.`, + metadata: { taskId }, + }); + + return updatedTask; +} + +export async function detectSequenceReplies( + workspaceId: string, + replies: Array<{ gmailThreadId: string; repliedAt: Date }>, +) { + const detected = []; + for (const reply of replies) { + const step = await db.query.sequenceEnrollmentSteps.findFirst({ + where: and( + eq(sequenceEnrollmentSteps.workspaceId, workspaceId), + eq(sequenceEnrollmentSteps.gmailThreadId, reply.gmailThreadId), + ), + }); + if (!step) continue; + + const enrollment = await db.query.sequenceEnrollments.findFirst({ + where: and( + eq(sequenceEnrollments.workspaceId, workspaceId), + eq(sequenceEnrollments.id, step.enrollmentId), + ), + }); + if (!enrollment || enrollment.status !== "active") continue; + + await db + .update(sequenceEnrollments) + .set({ status: "replied", nextStepDueAt: null, updatedAt: reply.repliedAt }) + .where(eq(sequenceEnrollments.id, enrollment.id)); + await recordActivity({ + workspaceId, + sequenceId: enrollment.sequenceId, + enrollmentId: enrollment.id, + personId: enrollment.personId, + type: "reply_detected", + message: "Gmail reply detected for this enrollment.", + metadata: { gmailThreadId: reply.gmailThreadId }, + }); + detected.push(enrollment.id); + } + + return detected; +} + +export async function listGmailIntegrations(workspaceId: string, userId: string) { + return db.query.gmailIntegrations.findMany({ + where: and(eq(gmailIntegrations.workspaceId, workspaceId), eq(gmailIntegrations.userId, userId)), + columns: { + id: true, + workspaceId: true, + userId: true, + email: true, + status: true, + grantedScopes: true, + tokenExpiresAt: true, + lastSyncAt: true, + timezone: true, + sendingWindow: true, + dailyLimit: true, + hourlyLimit: true, + createdAt: true, + updatedAt: true, + }, + }); +} + +export async function connectGmail(workspaceId: string, userId: string, input: ConnectGmailInput) { + const [integration] = await db + .insert(gmailIntegrations) + .values({ + workspaceId, + userId, + email: normalizeEmailAddress(input.email), + status: "connected", + grantedScopes: input.grantedScopes, + accessTokenEncrypted: encryptSecretToken(input.accessToken, tokenSecret()), + refreshTokenEncrypted: encryptSecretToken(input.refreshToken, tokenSecret()), + tokenExpiresAt: input.tokenExpiresAt ? new Date(input.tokenExpiresAt) : null, + timezone: input.timezone, + sendingWindow: defaultSendingWindow(input.sendingWindow), + dailyLimit: input.dailyLimit, + hourlyLimit: input.hourlyLimit, + }) + .onConflictDoUpdate({ + target: [gmailIntegrations.workspaceId, gmailIntegrations.userId, gmailIntegrations.email], + set: { + status: "connected", + grantedScopes: input.grantedScopes, + accessTokenEncrypted: encryptSecretToken(input.accessToken, tokenSecret()), + refreshTokenEncrypted: encryptSecretToken(input.refreshToken, tokenSecret()), + tokenExpiresAt: input.tokenExpiresAt ? new Date(input.tokenExpiresAt) : null, + timezone: input.timezone, + sendingWindow: defaultSendingWindow(input.sendingWindow), + dailyLimit: input.dailyLimit, + hourlyLimit: input.hourlyLimit, + updatedAt: new Date(), + }, + }) + .returning(); + + return integration; +} + +export async function disconnectGmail(workspaceId: string, userId: string, gmailIntegrationId: string) { + const integration = await db.query.gmailIntegrations.findFirst({ + where: and( + eq(gmailIntegrations.workspaceId, workspaceId), + eq(gmailIntegrations.userId, userId), + eq(gmailIntegrations.id, gmailIntegrationId), + ), + }); + if (!integration) throw new AppError("Gmail integration not found", STATUS_CODES.NOT_FOUND); + + await db.transaction(async (tx) => { + await tx + .update(sequenceEnrollments) + .set({ + status: "paused", + lastError: "Gmail sender was disconnected.", + pausedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(sequenceEnrollments.gmailIntegrationId, gmailIntegrationId)); + await tx.delete(gmailIntegrations).where(eq(gmailIntegrations.id, gmailIntegrationId)); + }); + + await recordActivity({ + workspaceId, + type: "gmail_warning", + message: `Gmail sender ${integration.email} was disconnected.`, + metadata: { gmailIntegrationId }, + }); + + return integration; +} + +export async function getSequenceDashboard(workspaceId: string, now = new Date()) { + const dayStart = startOfUtcDay(now); + const [active, sentToday, replies, failed, dueTasks, warnings, activity] = await Promise.all([ + db + .select({ totalCount: count() }) + .from(sequenceEnrollments) + .where( + and( + eq(sequenceEnrollments.workspaceId, workspaceId), + eq(sequenceEnrollments.status, "active"), + ), + ), + db + .select({ totalCount: count() }) + .from(sequenceActivityEvents) + .where( + and( + eq(sequenceActivityEvents.workspaceId, workspaceId), + inArray(sequenceActivityEvents.type, ["email_sent", "email_would_send"]), + gte(sequenceActivityEvents.createdAt, dayStart), + ), + ), + db + .select({ totalCount: count() }) + .from(sequenceActivityEvents) + .where( + and( + eq(sequenceActivityEvents.workspaceId, workspaceId), + eq(sequenceActivityEvents.type, "reply_detected"), + ), + ), + db + .select({ totalCount: count() }) + .from(sequenceEnrollmentSteps) + .where( + and( + eq(sequenceEnrollmentSteps.workspaceId, workspaceId), + eq(sequenceEnrollmentSteps.status, "failed"), + ), + ), + db.query.sequenceTasks.findMany({ + where: and( + eq(sequenceTasks.workspaceId, workspaceId), + eq(sequenceTasks.status, "open"), + lte(sequenceTasks.dueAt, now), + ), + with: { person: { columns: { id: true, name: true, email: true } } }, + orderBy: (table, { asc: orderAsc }) => [orderAsc(table.dueAt)], + limit: 20, + }), + db.query.gmailIntegrations.findMany({ + where: and( + eq(gmailIntegrations.workspaceId, workspaceId), + inArray(gmailIntegrations.status, ["reconnect_required", "disconnected"]), + ), + columns: { id: true, email: true, status: true, updatedAt: true }, + limit: 20, + }), + db.query.sequenceActivityEvents.findMany({ + where: eq(sequenceActivityEvents.workspaceId, workspaceId), + orderBy: (table, { desc: orderDesc }) => [orderDesc(table.createdAt)], + limit: 20, + }), + ]); + + return { + counters: { + activeEnrollments: Number(active[0]?.totalCount ?? 0), + emailsSentToday: Number(sentToday[0]?.totalCount ?? 0), + repliesDetected: Number(replies[0]?.totalCount ?? 0), + failedSteps: Number(failed[0]?.totalCount ?? 0), + }, + dueTasks, + gmailWarnings: warnings, + recentActivity: activity, + }; +} + +export async function previewUnsubscribe(token: string) { + const tokenHash = hashPublicToken(token); + const unsubscribeToken = await db.query.sequenceUnsubscribeTokens.findFirst({ + where: eq(sequenceUnsubscribeTokens.tokenHash, tokenHash), + with: { + sequence: { columns: { id: true, name: true } }, + }, + }); + + if (!unsubscribeToken) { + throw new AppError("Unsubscribe link not found", STATUS_CODES.NOT_FOUND); + } + + const suppression = await db.query.sequenceSuppressions.findFirst({ + where: and( + eq(sequenceSuppressions.workspaceId, unsubscribeToken.workspaceId), + eq(sequenceSuppressions.email, unsubscribeToken.email), + ), + }); + + return { + email: unsubscribeToken.email, + sequence: unsubscribeToken.sequence, + alreadyUnsubscribed: Boolean(suppression), + }; +} + +export async function confirmUnsubscribe(token: string, now = new Date()) { + const tokenHash = hashPublicToken(token); + const unsubscribeToken = await db.query.sequenceUnsubscribeTokens.findFirst({ + where: eq(sequenceUnsubscribeTokens.tokenHash, tokenHash), + with: { + sequence: { columns: { id: true, name: true } }, + }, + }); + + if (!unsubscribeToken) { + throw new AppError("Unsubscribe link not found", STATUS_CODES.NOT_FOUND); + } + + await db.transaction(async (tx) => { + await tx + .insert(sequenceSuppressions) + .values({ + workspaceId: unsubscribeToken.workspaceId, + email: unsubscribeToken.email, + reason: "unsubscribe", + source: "public_unsubscribe", + }) + .onConflictDoNothing(); + await tx + .update(sequenceUnsubscribeTokens) + .set({ consumedAt: now }) + .where(eq(sequenceUnsubscribeTokens.id, unsubscribeToken.id)); + await tx + .update(sequenceEnrollments) + .set({ + status: "unsubscribed", + nextStepDueAt: null, + updatedAt: now, + }) + .where(eq(sequenceEnrollments.id, unsubscribeToken.enrollmentId)); + }); + + await recordActivity({ + workspaceId: unsubscribeToken.workspaceId, + sequenceId: unsubscribeToken.sequenceId, + enrollmentId: unsubscribeToken.enrollmentId, + personId: unsubscribeToken.personId, + type: "unsubscribed", + message: `${unsubscribeToken.email} unsubscribed.`, + }); + + return { + email: unsubscribeToken.email, + sequence: unsubscribeToken.sequence, + }; +} + +export async function generateSequenceEmailContent(prompt: string) { + return generateEmailContent(prompt); +} diff --git a/apps/api/src/workers/sequence-worker.ts b/apps/api/src/workers/sequence-worker.ts new file mode 100644 index 0000000..d698e92 --- /dev/null +++ b/apps/api/src/workers/sequence-worker.ts @@ -0,0 +1,32 @@ +import { logger } from "@/config/logger.config.js"; +import { executeDueSequenceWork } from "@/services/sequence.service.js"; + +const POLL_INTERVAL_MS = 60_000; +let running = false; + +export async function runSequenceWorkerTick() { + if (running) { + logger.warn("Sequence worker tick skipped because the previous tick is still running"); + return []; + } + + running = true; + try { + const results = await executeDueSequenceWork(new Date()); + logger.info({ processed: results.length }, "Sequence worker tick completed"); + return results; + } catch (error) { + logger.error({ error }, "Sequence worker tick failed"); + throw error; + } finally { + running = false; + } +} + +if (import.meta.main) { + logger.info({ intervalMs: POLL_INTERVAL_MS }, "Sequence worker started"); + setInterval(() => { + void runSequenceWorkerTick(); + }, POLL_INTERVAL_MS); + void runSequenceWorkerTick(); +} diff --git a/apps/api/test/routes/routes.test.ts b/apps/api/test/routes/routes.test.ts index 5428c59..971a98e 100644 --- a/apps/api/test/routes/routes.test.ts +++ b/apps/api/test/routes/routes.test.ts @@ -32,6 +32,27 @@ const controller = vi.hoisted(() => { createCustomFieldDefinition: respond, updateCustomFieldDefinition: respond, deleteCustomFieldDefinition: respond, + listSequencesController: respond, + createSequenceController: respond, + getSequenceController: respond, + updateSequenceController: respond, + updateSequenceStepController: respond, + publishSequenceController: respond, + archiveSequenceController: respond, + deleteSequenceController: respond, + enrollPeopleController: respond, + listSequenceEnrollmentsController: respond, + listSequenceActivityController: respond, + pauseEnrollmentController: respond, + resumeEnrollmentController: respond, + completeSequenceTaskController: respond, + getSequenceDashboardController: respond, + listGmailIntegrationsController: respond, + connectGmailController: respond, + disconnectGmailController: respond, + generateEmailContentController: respond, + previewUnsubscribeController: respond, + confirmUnsubscribeController: respond, authHandler: vi.fn(() => new Response("auth")), }; }); @@ -74,6 +95,29 @@ vi.mock("@/controllers/crm-custom-fields.controller.js", () => ({ updateCustomFieldDefinition: controller.updateCustomFieldDefinition, deleteCustomFieldDefinition: controller.deleteCustomFieldDefinition, })); +vi.mock("@/controllers/sequences.controller.js", () => ({ + listSequencesController: controller.listSequencesController, + createSequenceController: controller.createSequenceController, + getSequenceController: controller.getSequenceController, + updateSequenceController: controller.updateSequenceController, + updateSequenceStepController: controller.updateSequenceStepController, + publishSequenceController: controller.publishSequenceController, + archiveSequenceController: controller.archiveSequenceController, + deleteSequenceController: controller.deleteSequenceController, + enrollPeopleController: controller.enrollPeopleController, + listSequenceEnrollmentsController: controller.listSequenceEnrollmentsController, + listSequenceActivityController: controller.listSequenceActivityController, + pauseEnrollmentController: controller.pauseEnrollmentController, + resumeEnrollmentController: controller.resumeEnrollmentController, + completeSequenceTaskController: controller.completeSequenceTaskController, + getSequenceDashboardController: controller.getSequenceDashboardController, + listGmailIntegrationsController: controller.listGmailIntegrationsController, + connectGmailController: controller.connectGmailController, + disconnectGmailController: controller.disconnectGmailController, + generateEmailContentController: controller.generateEmailContentController, + previewUnsubscribeController: controller.previewUnsubscribeController, + confirmUnsubscribeController: controller.confirmUnsubscribeController, +})); vi.mock("@/middlewares/auth-middleware.js", () => ({ authMiddleware: (_c: Context, next: Next) => next(), })); @@ -95,6 +139,8 @@ import { healthRoutes } from "@/routes/health.route.js"; import { onboardingRoutes } from "@/routes/onboarding.route.js"; import { orgRoutes } from "@/routes/org.route.js"; import { peopleRoutes } from "@/routes/people.route.js"; +import { sequenceRoutes } from "@/routes/sequences.route.js"; +import { sequenceUnsubscribeRoutes } from "@/routes/sequence-unsubscribe.route.js"; import { registerRoutes } from "@/routes/index.js"; const jsonRequest = (method: string, body?: unknown) => ({ @@ -114,6 +160,8 @@ describe("API route wiring without database infrastructure", () => { [analyticsRoutes, "/win-rate", "GET"], [onboardingRoutes, "/", "GET"], [onboardingRoutes, "/crm-tour/complete", "POST"], + [sequenceRoutes, "/dashboard", "GET"], + [sequenceRoutes, "/gmail", "GET"], ])("dispatches %s %s routes", async (app, path, method) => { expect((await app.request(path, { method })).status).toBe(200); }); @@ -162,11 +210,67 @@ describe("API route wiring without database infrastructure", () => { expect(controller.authHandler).toHaveBeenCalledTimes(2); }); + it("dispatches every sequence management route with validated values", async () => { + const id = faker.string.uuid(); + const stepId = faker.string.uuid(); + const enrollmentId = faker.string.uuid(); + const taskId = faker.string.uuid(); + const gmailIntegrationId = faker.string.uuid(); + const token = "a".repeat(32); + const step = { + type: "email", + name: "Intro", + position: 0, + config: { subject: "Hello", body: "Hi there" }, + }; + const cases: Array<[string, RequestInit | undefined]> = [ + ["/", undefined], + ["/", jsonRequest("POST", { name: "Outbound" })], + ["/generate-email", jsonRequest("POST", { prompt: "Write an intro" })], + [ + "/gmail", + jsonRequest("POST", { + email: "sender@example.test", + grantedScopes: [ + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.metadata", + ], + accessToken: "access", + refreshToken: "refresh", + }), + ], + [`/gmail/${gmailIntegrationId}`, { method: "DELETE" }], + [`/enrollments/${enrollmentId}/pause`, { method: "PATCH" }], + [`/enrollments/${enrollmentId}/resume`, { method: "PATCH" }], + [`/tasks/${taskId}/complete`, { method: "PATCH" }], + [`/${id}`, undefined], + [`/${id}`, jsonRequest("PATCH", { name: "Outbound 2", steps: [step] })], + [`/${id}/steps/${stepId}`, jsonRequest("PATCH", { config: { subject: "New" } })], + [`/${id}/publish`, { method: "POST" }], + [`/${id}/archive`, { method: "POST" }], + [`/${id}/enrollments`, jsonRequest("POST", { personIds: [id], gmailIntegrationId })], + [`/${id}/enrollments`, undefined], + [`/${id}/activity`, undefined], + [`/${id}`, { method: "DELETE" }], + ]; + + for (const [path, init] of cases) { + expect((await sequenceRoutes.request(path, init)).status).toBe(200); + } + + expect((await sequenceUnsubscribeRoutes.request(`/${token}`)).status).toBe(200); + expect((await sequenceUnsubscribeRoutes.request(`/${token}`, { method: "POST" })).status).toBe( + 200, + ); + }); + it("registers the complete API and constructs the server export", async () => { const app = new Hono(); registerRoutes(app); expect((await app.request("/health")).status).toBe(200); + expect((await app.request("/sequences")).status).toBe(200); + expect((await app.request(`/sequence-unsubscribe/${"a".repeat(32)}`)).status).toBe(200); expect(server.port).toBe(3001); expect(server.fetch).toEqual(expect.any(Function)); expect((await healthRoutes.request("/")).status).toBe(200); diff --git a/apps/api/test/services/sequence-engine.test.ts b/apps/api/test/services/sequence-engine.test.ts new file mode 100644 index 0000000..be573fc --- /dev/null +++ b/apps/api/test/services/sequence-engine.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { AppError } from "@/lib/app-error.js"; +import { + createPublicToken, + decryptSecretToken, + encryptSecretToken, + hashPublicToken, +} from "@/lib/token-crypto.js"; +import { + DEFAULT_SENDING_WINDOW, + firstStepDueAt, + isWithinSendingWindow, + nextDueAtAfterStep, + nextSendingWindowSlot, + normalizeEmailAddress, + toStepSnapshot, + validatePublishableSteps, +} from "@/services/sequence-engine.js"; +import type { SequenceDraftStepInput } from "@workspace/validators/schemas/sequence"; + +const emailStep = (position = 0): SequenceDraftStepInput => ({ + type: "email", + name: "Intro email", + position, + config: { subject: "Hello", body: "Hi there" }, +}); + +describe("sequence engine behavior", () => { + it("validates publishable linear steps and normalizes snapshots", () => { + const steps = validatePublishableSteps([ + { + type: "wait", + name: "Wait", + position: 1, + config: { days: "2" }, + }, + emailStep(0), + ]); + + expect(steps.map((step) => step.position)).toEqual([0, 1]); + expect(toStepSnapshot(steps[1]!).config).toEqual({ days: 2 }); + }); + + it("rejects incomplete email content and non-linear positions before publishing", () => { + expect(() => + validatePublishableSteps([ + { type: "email", name: "Bad email", position: 0, config: { subject: "" } }, + ]), + ).toThrow(AppError); + expect(() => validatePublishableSteps([emailStep(1)])).toThrow(AppError); + }); + + it("schedules first emails and follow-ups inside the sending window", () => { + const fridayAfterHours = new Date("2026-07-10T18:15:00.000Z"); + const mondayMorning = new Date("2026-07-13T09:00:00.000Z"); + const steps = [ + toStepSnapshot(emailStep(0)), + toStepSnapshot({ type: "wait", name: "Wait", position: 1, config: { days: 2 } }), + toStepSnapshot(emailStep(2)), + ]; + + expect(isWithinSendingWindow(mondayMorning, DEFAULT_SENDING_WINDOW)).toBe(true); + expect(nextSendingWindowSlot(fridayAfterHours, DEFAULT_SENDING_WINDOW)).toEqual(mondayMorning); + expect(firstStepDueAt(steps, fridayAfterHours, DEFAULT_SENDING_WINDOW)).toEqual(mondayMorning); + expect(nextDueAtAfterStep(steps, 1, fridayAfterHours, DEFAULT_SENDING_WINDOW)).toEqual( + mondayMorning, + ); + }); + + it("normalizes email addresses and protects token material", () => { + const token = createPublicToken(); + const secret = "x".repeat(32); + const encrypted = encryptSecretToken("gmail-refresh-token", secret); + + expect(token).toHaveLength(43); + expect(hashPublicToken(token)).toHaveLength(64); + expect(encrypted).not.toContain("gmail-refresh-token"); + expect(decryptSecretToken(encrypted, secret)).toBe("gmail-refresh-token"); + expect(normalizeEmailAddress(" Ada@Example.TEST ")).toBe("ada@example.test"); + }); +}); diff --git a/apps/api/test/workers/sequence-worker.test.ts b/apps/api/test/workers/sequence-worker.test.ts new file mode 100644 index 0000000..361c6c7 --- /dev/null +++ b/apps/api/test/workers/sequence-worker.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + executeDueSequenceWork: vi.fn(), + logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, +})); + +vi.mock("@/services/sequence.service.js", () => ({ + executeDueSequenceWork: mocks.executeDueSequenceWork, +})); +vi.mock("@/config/logger.config.js", () => ({ + logger: mocks.logger, +})); + +import { runSequenceWorkerTick } from "@/workers/sequence-worker.js"; + +describe("sequence worker", () => { + beforeEach(() => { + mocks.executeDueSequenceWork.mockResolvedValue([{ stepId: "step", status: "completed" }]); + }); + + it("runs one due-work tick and logs the processed count", async () => { + await expect(runSequenceWorkerTick()).resolves.toEqual([ + { stepId: "step", status: "completed" }, + ]); + + expect(mocks.executeDueSequenceWork).toHaveBeenCalledWith(expect.any(Date)); + expect(mocks.logger.info).toHaveBeenCalledWith( + { processed: 1 }, + "Sequence worker tick completed", + ); + }); + + it("does not overlap concurrent ticks", async () => { + let resolveWork: (value: never[]) => void = () => {}; + mocks.executeDueSequenceWork.mockReturnValueOnce( + new Promise((resolve) => { + resolveWork = resolve; + }), + ); + + const firstTick = runSequenceWorkerTick(); + await expect(runSequenceWorkerTick()).resolves.toEqual([]); + resolveWork([]); + await firstTick; + + expect(mocks.executeDueSequenceWork).toHaveBeenCalledTimes(1); + expect(mocks.logger.warn).toHaveBeenCalledWith( + "Sequence worker tick skipped because the previous tick is still running", + ); + }); +}); diff --git a/apps/api/turbo.json b/apps/api/turbo.json index 04f8720..717f362 100644 --- a/apps/api/turbo.json +++ b/apps/api/turbo.json @@ -12,8 +12,10 @@ "BETTER_AUTH_SECRET", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", + "GROQ_API_KEY", "BETTER_AUTH_URL", - "WEB_URL" + "WEB_URL", + "SEQUENCE_LIVE_SEND_ENABLED" ], "inputs": ["$TURBO_DEFAULT$", ".env*"] }, diff --git a/apps/web/app/(crm)/dashboard/page.tsx b/apps/web/app/(crm)/dashboard/page.tsx index c836c10..c021c69 100644 --- a/apps/web/app/(crm)/dashboard/page.tsx +++ b/apps/web/app/(crm)/dashboard/page.tsx @@ -6,6 +6,7 @@ import { Button } from "@workspace/ui/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components/ui/card"; import { Skeleton } from "@workspace/ui/components/ui/skeleton"; import { StatsCards } from "@/components/crm/dashboard/stats-cards"; +import { SequenceAutomationPanel } from "@/components/crm/dashboard/sequence-automation-panel"; import { PipelineChart } from "@/components/crm/dashboard/pipeline-chart"; import { PeopleStatusChart } from "@/components/crm/dashboard/people-status-chart"; import { WinRateCard } from "@/components/crm/dashboard/win-rate-card"; @@ -133,6 +134,8 @@ export default function Dashboard() { /> + +
; +}) { + const { sequenceId } = await params; + return ; +} diff --git a/apps/web/app/(crm)/sequences/[sequenceId]/page.tsx b/apps/web/app/(crm)/sequences/[sequenceId]/page.tsx new file mode 100644 index 0000000..f77fdf2 --- /dev/null +++ b/apps/web/app/(crm)/sequences/[sequenceId]/page.tsx @@ -0,0 +1,10 @@ +import { SequenceDetailWorkspace } from "@/components/sequences/sequence-detail-workspace"; + +export default async function SequenceDetailRoute({ + params, +}: { + params: Promise<{ sequenceId: string }>; +}) { + const { sequenceId } = await params; + return ; +} diff --git a/apps/web/app/(crm)/sequences/new/page.tsx b/apps/web/app/(crm)/sequences/new/page.tsx new file mode 100644 index 0000000..194960c --- /dev/null +++ b/apps/web/app/(crm)/sequences/new/page.tsx @@ -0,0 +1,5 @@ +import { NewSequenceRedirect } from "@/components/sequences/new-sequence-redirect"; + +export default function NewSequencePage() { + return ; +} diff --git a/apps/web/app/(crm)/sequences/page.tsx b/apps/web/app/(crm)/sequences/page.tsx new file mode 100644 index 0000000..0b6b647 --- /dev/null +++ b/apps/web/app/(crm)/sequences/page.tsx @@ -0,0 +1,5 @@ +import { SequencesListWorkspace } from "@/components/sequences/sequences-list-workspace"; + +export default function SequencesPage() { + return ; +} diff --git a/apps/web/app/unsubscribe/[token]/page.tsx b/apps/web/app/unsubscribe/[token]/page.tsx new file mode 100644 index 0000000..bac6680 --- /dev/null +++ b/apps/web/app/unsubscribe/[token]/page.tsx @@ -0,0 +1,10 @@ +import { UnsubscribeConfirmation } from "@/components/sequences/unsubscribe-confirmation"; + +export default async function UnsubscribeRoute({ + params, +}: { + params: Promise<{ token: string }>; +}) { + const { token } = await params; + return ; +} diff --git a/apps/web/components/crm/dashboard/sequence-automation-panel.tsx b/apps/web/components/crm/dashboard/sequence-automation-panel.tsx new file mode 100644 index 0000000..311ead3 --- /dev/null +++ b/apps/web/components/crm/dashboard/sequence-automation-panel.tsx @@ -0,0 +1,140 @@ +"use client"; + +import Link from "next/link"; +import { AlertTriangle, ArrowUpRight, Check, MailCheck, Reply, Send, Users } from "lucide-react"; +import { Badge } from "@workspace/ui/components/ui/badge"; +import { Button } from "@workspace/ui/components/ui/button"; +import { Skeleton } from "@workspace/ui/components/ui/skeleton"; +import { + useCompleteSequenceTask, + useSequenceDashboard, +} from "@/hooks/queries/use-sequences"; + +function Counter({ + label, + value, + icon: Icon, +}: { + label: string; + value: number; + icon: React.ElementType; +}) { + return ( +
+
+ {label} + +
+

{value.toLocaleString()}

+
+ ); +} + +export function SequenceAutomationPanel() { + const { data, isLoading } = useSequenceDashboard(); + const completeTaskMutation = useCompleteSequenceTask(); + + if (isLoading) { + return ; + } + + const counters = data?.counters ?? { + activeEnrollments: 0, + emailsSentToday: 0, + repliesDetected: 0, + failedSteps: 0, + }; + + return ( +
+
+
+

Sequence Automation

+

Due work, sender health, and recent events.

+
+ +
+ +
+ + + + +
+ +
+
+

Due tasks

+ {(data?.dueTasks ?? []).slice(0, 4).map((task) => ( +
+
+

{task.title}

+

+ {task.person?.name ?? task.type.replace("_", " ")} +

+
+ +
+ ))} + {data?.dueTasks.length === 0 && ( +

+ No due sequence tasks. +

+ )} +
+ +
+

Gmail warnings

+ {(data?.gmailWarnings ?? []).slice(0, 4).map((warning) => ( +
+
+ +

{warning.email}

+
+ + {warning.status.replace("_", " ")} + +
+ ))} + {data?.gmailWarnings.length === 0 && ( +

+ Gmail senders are healthy. +

+ )} +
+ +
+

Recent activity

+ {(data?.recentActivity ?? []).slice(0, 4).map((event) => ( +
+

{event.message}

+

+ {new Date(event.createdAt).toLocaleString()} +

+
+ ))} + {data?.recentActivity.length === 0 && ( +

+ No sequence activity yet. +

+ )} +
+
+
+ ); +} diff --git a/apps/web/components/sequences/new-sequence-redirect.tsx b/apps/web/components/sequences/new-sequence-redirect.tsx new file mode 100644 index 0000000..d12b8fb --- /dev/null +++ b/apps/web/components/sequences/new-sequence-redirect.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { LoadingState } from "@/components/shared/loading-state"; +import { useCreateSequence } from "@/hooks/queries/use-sequences"; + +export function NewSequenceRedirect() { + const router = useRouter(); + const { mutate } = useCreateSequence(); + const startedRef = useRef(false); + + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + mutate( + { name: `Untitled sequence ${new Date().toLocaleDateString()}` }, + { onSuccess: (sequence) => router.replace(`/sequences/${sequence.id}`) }, + ); + }, [mutate, router]); + + return ; +} diff --git a/apps/web/components/sequences/sequence-detail-workspace.tsx b/apps/web/components/sequences/sequence-detail-workspace.tsx new file mode 100644 index 0000000..9461710 --- /dev/null +++ b/apps/web/components/sequences/sequence-detail-workspace.tsx @@ -0,0 +1,739 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { + ArrowLeft, + Check, + Clock, + Linkedin, + Mail, + Pause, + Play, + Save, + Send, + Sparkles, + SquareCheck, + Trash2, + UserPlus, + Workflow, +} from "lucide-react"; +import { Badge } from "@workspace/ui/components/ui/badge"; +import { Button } from "@workspace/ui/components/ui/button"; +import { Checkbox } from "@workspace/ui/components/ui/checkbox"; +import { Input } from "@workspace/ui/components/ui/input"; +import { Label } from "@workspace/ui/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@workspace/ui/components/ui/select"; +import { Skeleton } from "@workspace/ui/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/ui/tabs"; +import { Textarea } from "@workspace/ui/components/ui/textarea"; +import { cn } from "@workspace/ui/lib/utils"; +import { PageHeader } from "@/components/layout/page-header"; +import { EmptyState } from "@/components/shared/empty-state"; +import { ErrorState } from "@/components/shared/error-state"; +import { LoadingState } from "@/components/shared/loading-state"; +import { SequenceStatusBadge } from "@/components/sequences/sequence-status-badge"; +import { usePeople } from "@/hooks/queries/use-people"; +import { + useCompleteSequenceTask, + useEnrollPeople, + useGenerateEmailContent, + useGmailIntegrations, + usePauseEnrollment, + usePublishSequence, + useResumeEnrollment, + useSequence, + useSequenceActivity, + useSequenceDashboard, + useSequenceEnrollments, + useUpdateSequence, +} from "@/hooks/queries/use-sequences"; +import type { + SequenceActivityEvent, + SequenceEnrollment, + SequenceStep, + SequenceTask, +} from "@/types/sequence"; +import type { + SequenceDraftStepInput, + SequenceStepType, +} from "@workspace/validators/schemas/sequence"; + +const STEP_OPTIONS: Array<{ + type: SequenceStepType; + label: string; + icon: React.ElementType; + config: Record; +}> = [ + { type: "email", label: "Email", icon: Mail, config: { prompt: "", subject: "", body: "" } }, + { type: "wait", label: "Wait", icon: Clock, config: { days: 1 } }, + { + type: "linkedin_task", + label: "LinkedIn", + icon: Linkedin, + config: { prompt: "", title: "LinkedIn follow-up", body: "" }, + }, + { + type: "general_task", + label: "Task", + icon: SquareCheck, + config: { prompt: "", title: "Manual follow-up", body: "" }, + }, +]; + +function asString(value: unknown) { + return typeof value === "string" ? value : ""; +} + +function asNumber(value: unknown, fallback: number) { + return typeof value === "number" ? value : fallback; +} + +function toDraftStep(step: SequenceStep): SequenceDraftStepInput { + return { + id: step.id, + type: step.type, + name: step.name, + position: step.position, + config: step.config, + }; +} + +function StepIcon({ type, className }: { type: SequenceStepType; className?: string }) { + const option = STEP_OPTIONS.find((step) => step.type === type); + const Icon = option?.icon ?? Workflow; + return ; +} + +function StepPalette({ onAdd }: { onAdd: (type: SequenceStepType) => void }) { + return ( +
+

Steps

+
+ {STEP_OPTIONS.map((option) => ( + + ))} +
+
+ ); +} + +function FlowCanvas({ + steps, + selectedStepId, + onSelect, + onRemove, +}: { + steps: SequenceDraftStepInput[]; + selectedStepId: string | null; + onSelect: (stepId: string) => void; + onRemove: (stepId: string) => void; +}) { + if (steps.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {steps.map((step, index) => { + const isSelected = step.id === selectedStepId; + return ( +
+ + + {index < steps.length - 1 &&
} +
+ ); + })} +
+
+ ); +} + +function StepInspector({ + step, + onChange, + onGenerateEmail, + generating, +}: { + step?: SequenceDraftStepInput; + onChange: (step: SequenceDraftStepInput) => void; + onGenerateEmail: (step: SequenceDraftStepInput) => void; + generating: boolean; +}) { + if (!step) { + return ( +
+

Select a step to edit.

+
+ ); + } + + function updateConfig(config: Record) { + onChange({ ...step!, config: { ...step!.config, ...config } }); + } + + return ( +
+
+ +

Inspector

+
+
+
+ + onChange({ ...step, name: event.target.value })} + /> +
+ + {step.type === "email" && ( + <> +
+ +