From 4c8cb9a9d9c59e6dce3e570cd05b15475625373a Mon Sep 17 00:00:00 2001 From: Charlie <31941002+CharlieMc0@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:11:09 -0700 Subject: [PATCH 1/2] feat: add phone_calls WatermelonDB table for call persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new phone_calls table (schema v19 → v20) to persist Bland AI phone call data locally. Includes model, CRUD operations, types, and barrel exports following the existing project/media table patterns. Co-Authored-By: Claude Opus 4.6 --- src/lib/db/phone_call/index.ts | 15 ++++ src/lib/db/phone_call/models.ts | 15 ++++ src/lib/db/phone_call/operations.ts | 106 ++++++++++++++++++++++++++++ src/lib/db/phone_call/types.ts | 56 +++++++++++++++ src/lib/db/schema.ts | 39 +++++++++- src/react/index.ts | 15 ++++ 6 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 src/lib/db/phone_call/index.ts create mode 100644 src/lib/db/phone_call/models.ts create mode 100644 src/lib/db/phone_call/operations.ts create mode 100644 src/lib/db/phone_call/types.ts diff --git a/src/lib/db/phone_call/index.ts b/src/lib/db/phone_call/index.ts new file mode 100644 index 000000000..aca89a7b1 --- /dev/null +++ b/src/lib/db/phone_call/index.ts @@ -0,0 +1,15 @@ +export { PhoneCall } from "./models"; +export { + type StoredPhoneCall, + type PhoneCallStatus, + type CreatePhoneCallOptions, + type UpdatePhoneCallOptions, +} from "./types"; +export { + type PhoneCallOperationsContext, + phoneCallToStored, + createPhoneCallOp, + getPhoneCallByOfferOp, + getPhoneCallsByConversationOp, + updatePhoneCallOp, +} from "./operations"; diff --git a/src/lib/db/phone_call/models.ts b/src/lib/db/phone_call/models.ts new file mode 100644 index 000000000..ca2af576a --- /dev/null +++ b/src/lib/db/phone_call/models.ts @@ -0,0 +1,15 @@ +import { Model } from "@nozbe/watermelondb"; +import { text, date } from "@nozbe/watermelondb/decorators"; + +export class PhoneCall extends Model { + static table = "phone_calls"; + + @text("call_id") callId!: string; + @text("conversation_id") conversationId!: string; + @text("offer_message_id") offerMessageId!: string; + @text("status") status!: string; + @text("request") request!: string; + @text("response") response!: string; + @date("created_at") createdAt!: Date; + @date("updated_at") updatedAt!: Date; +} diff --git a/src/lib/db/phone_call/operations.ts b/src/lib/db/phone_call/operations.ts new file mode 100644 index 000000000..a3b008b4f --- /dev/null +++ b/src/lib/db/phone_call/operations.ts @@ -0,0 +1,106 @@ +import { Q } from "@nozbe/watermelondb"; +import type { Database, Collection } from "@nozbe/watermelondb"; + +import { PhoneCall } from "./models"; +import type { + StoredPhoneCall, + PhoneCallStatus, + CreatePhoneCallOptions, + UpdatePhoneCallOptions, +} from "./types"; + +export function phoneCallToStored(record: PhoneCall): StoredPhoneCall { + return { + uniqueId: record.id, + callId: record.callId, + conversationId: record.conversationId, + offerMessageId: record.offerMessageId, + status: record.status as PhoneCallStatus, + request: record.request, + response: record.response, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +} + +export interface PhoneCallOperationsContext { + database: Database; + phoneCallsCollection: Collection; +} + +/** + * Create a new phone call record. + */ +export async function createPhoneCallOp( + ctx: PhoneCallOperationsContext, + opts: CreatePhoneCallOptions +): Promise { + const created = await ctx.database.write(async () => { + return await ctx.phoneCallsCollection.create((record) => { + record._setRaw("call_id", opts.callId); + record._setRaw("conversation_id", opts.conversationId); + record._setRaw("offer_message_id", opts.offerMessageId); + record._setRaw("status", opts.status); + record._setRaw("request", opts.request ?? ""); + record._setRaw("response", opts.response ?? ""); + }); + }); + + return phoneCallToStored(created); +} + +/** + * Get a phone call record by the offer message ID that created it. + */ +export async function getPhoneCallByOfferOp( + ctx: PhoneCallOperationsContext, + offerMessageId: string +): Promise { + const results = await ctx.phoneCallsCollection + .query(Q.where("offer_message_id", offerMessageId)) + .fetch(); + + return results.length > 0 ? phoneCallToStored(results[0]) : null; +} + +/** + * Get all phone call records for a conversation, newest first. + */ +export async function getPhoneCallsByConversationOp( + ctx: PhoneCallOperationsContext, + conversationId: string +): Promise { + const results = await ctx.phoneCallsCollection + .query( + Q.where("conversation_id", conversationId), + Q.sortBy("created_at", Q.desc) + ) + .fetch(); + + return results.map(phoneCallToStored); +} + +/** + * Update a phone call record looked up by offer message ID. + */ +export async function updatePhoneCallOp( + ctx: PhoneCallOperationsContext, + offerMessageId: string, + opts: UpdatePhoneCallOptions +): Promise { + const results = await ctx.phoneCallsCollection + .query(Q.where("offer_message_id", offerMessageId)) + .fetch(); + + if (results.length > 0) { + await ctx.database.write(async () => { + await results[0].update((record) => { + if (opts.status !== undefined) record._setRaw("status", opts.status); + if (opts.response !== undefined) + record._setRaw("response", opts.response); + }); + }); + return true; + } + return false; +} diff --git a/src/lib/db/phone_call/types.ts b/src/lib/db/phone_call/types.ts new file mode 100644 index 000000000..2d3871d46 --- /dev/null +++ b/src/lib/db/phone_call/types.ts @@ -0,0 +1,56 @@ +/** + * Phone call types for persisting Bland AI call data. + * + * A phone call record tracks the lifecycle of an AI-placed phone call, + * from creation through completion or failure, with cached API responses. + */ + +/** + * Stored representation of a phone call in the database. + */ +export interface StoredPhoneCall { + /** WatermelonDB internal ID */ + uniqueId: string; + /** Bland API call ID (indexed for queries) */ + callId: string; + /** Which conversation this call belongs to (indexed) */ + conversationId: string; + /** uniqueID of the tool result message that created the offer (indexed) */ + offerMessageId: string; + /** Call lifecycle status */ + status: PhoneCallStatus; + /** JSON-stringified create request */ + request: string; + /** JSON-stringified API response (transcript, summary, etc.) */ + response: string; + /** When the call record was created */ + createdAt: Date; + /** When the call record was last updated */ + updatedAt: Date; +} + +export type PhoneCallStatus = + | "placing" + | "in_progress" + | "completed" + | "failed"; + +/** + * Options for creating a new phone call record. + */ +export interface CreatePhoneCallOptions { + callId: string; + conversationId: string; + offerMessageId: string; + status: PhoneCallStatus; + request?: string; + response?: string; +} + +/** + * Options for updating an existing phone call record. + */ +export interface UpdatePhoneCallOptions { + status?: PhoneCallStatus; + response?: string; +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index b676ee15d..4504452d8 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -11,6 +11,7 @@ import type { Class } from "@nozbe/watermelondb/types"; import { Conversation, Message } from "./chat/models"; import { Media } from "./media/models"; import { VaultMemory } from "./memoryVault/models"; +import { PhoneCall } from "./phone_call/models"; import { Project } from "./project/models"; import { ModelPreference } from "./settings/models"; import { UserPreference } from "./userPreferences/models"; @@ -38,8 +39,9 @@ import { VaultFolder } from "./vaultFolders/models"; * - v17: Added image_model column to history table for AI-generated image model tracking * - v18: Added vault_folders table and folder_id column to memory_vault for folder organization * - v19: Added user_id column to memory_vault for multi-user server-side scoping + * - v20: Added phone_calls table for persisting phone call data */ -export const SDK_SCHEMA_VERSION = 19; +export const SDK_SCHEMA_VERSION = 20; /** * Combined WatermelonDB schema for all SDK storage modules. @@ -175,6 +177,20 @@ export const sdkSchema = appSchema({ { name: "is_deleted", type: "boolean", isIndexed: true }, ], }), + // Phone call records (Bland AI call data) + tableSchema({ + name: "phone_calls", + columns: [ + { name: "call_id", type: "string", isIndexed: true }, + { name: "conversation_id", type: "string", isIndexed: true }, + { name: "offer_message_id", type: "string", isIndexed: true }, + { name: "status", type: "string" }, + { name: "request", type: "string", isOptional: true }, + { name: "response", type: "string", isOptional: true }, + { name: "created_at", type: "number" }, + { name: "updated_at", type: "number" }, + ], + }), // Media library storage (images, videos, audio, documents) tableSchema({ name: "media", @@ -236,6 +252,7 @@ export const sdkSchema = appSchema({ * - v16 → v17: Added `image_model` column to history table for AI-generated image model tracking * - v17 → v18: Added `vault_folders` table (with scope) and `folder_id` column to memory_vault for folder organization * - v18 → v19: Added `user_id` column to memory_vault for multi-user server-side scoping + * - v19 → v20: Added `phone_calls` table for persisting phone call data */ export const sdkMigrations = schemaMigrations({ migrations: [ @@ -481,6 +498,25 @@ export const sdkMigrations = schemaMigrations({ }), ], }, + // v19 -> v20: Added phone_calls table for persisting phone call data + { + toVersion: 20, + steps: [ + createTable({ + name: "phone_calls", + columns: [ + { name: "call_id", type: "string", isIndexed: true }, + { name: "conversation_id", type: "string", isIndexed: true }, + { name: "offer_message_id", type: "string", isIndexed: true }, + { name: "status", type: "string" }, + { name: "request", type: "string", isOptional: true }, + { name: "response", type: "string", isOptional: true }, + { name: "created_at", type: "number" }, + { name: "updated_at", type: "number" }, + ], + }), + ], + }, ], }); @@ -505,6 +541,7 @@ export const sdkModelClasses: Class[] = [ Message, Conversation, Project, + PhoneCall, VaultMemory, VaultFolder, Media, diff --git a/src/react/index.ts b/src/react/index.ts index edd31329a..c7af6e826 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -188,6 +188,21 @@ export { updateMessageFeedbackOp, } from "../lib/db/chat"; +// Phone call storage exports +export { + PhoneCall, + type StoredPhoneCall, + type PhoneCallStatus, + type CreatePhoneCallOptions, + type UpdatePhoneCallOptions, + type PhoneCallOperationsContext, + phoneCallToStored, + createPhoneCallOp, + getPhoneCallByOfferOp, + getPhoneCallsByConversationOp, + updatePhoneCallOp, +} from "../lib/db/phone_call"; + // Project storage exports export { createProjectOp, From 79a41147a4af3602819e22b58f0f6ca4dc385c53 Mon Sep 17 00:00:00 2001 From: Charlie <31941002+CharlieMc0@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:26:08 -0700 Subject: [PATCH 2/2] feat: add deletePhoneCallsByConversationOp Hard-deletes all phone call records for a conversation. Prevents unbounded table growth when conversations are cleaned up. Co-Authored-By: Claude Opus 4.6 --- src/lib/db/phone_call/index.ts | 1 + src/lib/db/phone_call/operations.ts | 21 +++++++++++++++++++++ src/react/index.ts | 1 + 3 files changed, 23 insertions(+) diff --git a/src/lib/db/phone_call/index.ts b/src/lib/db/phone_call/index.ts index aca89a7b1..f24a746a0 100644 --- a/src/lib/db/phone_call/index.ts +++ b/src/lib/db/phone_call/index.ts @@ -12,4 +12,5 @@ export { getPhoneCallByOfferOp, getPhoneCallsByConversationOp, updatePhoneCallOp, + deletePhoneCallsByConversationOp, } from "./operations"; diff --git a/src/lib/db/phone_call/operations.ts b/src/lib/db/phone_call/operations.ts index a3b008b4f..5c52d06e8 100644 --- a/src/lib/db/phone_call/operations.ts +++ b/src/lib/db/phone_call/operations.ts @@ -104,3 +104,24 @@ export async function updatePhoneCallOp( } return false; } + +/** + * Delete all phone call records for a conversation. + */ +export async function deletePhoneCallsByConversationOp( + ctx: PhoneCallOperationsContext, + conversationId: string +): Promise { + const results = await ctx.phoneCallsCollection + .query(Q.where("conversation_id", conversationId)) + .fetch(); + + if (results.length > 0) { + await ctx.database.write(async () => { + for (const record of results) { + await record.destroyPermanently(); + } + }); + } + return results.length; +} diff --git a/src/react/index.ts b/src/react/index.ts index c7af6e826..2c423c1d8 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -201,6 +201,7 @@ export { getPhoneCallByOfferOp, getPhoneCallsByConversationOp, updatePhoneCallOp, + deletePhoneCallsByConversationOp, } from "../lib/db/phone_call"; // Project storage exports