Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/lib/db/phone_call/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export { PhoneCall } from "./models";
export {
type StoredPhoneCall,
type PhoneCallStatus,
type CreatePhoneCallOptions,
type UpdatePhoneCallOptions,
} from "./types";
export {
type PhoneCallOperationsContext,
phoneCallToStored,
createPhoneCallOp,
getPhoneCallByOfferOp,
getPhoneCallsByConversationOp,
updatePhoneCallOp,
deletePhoneCallsByConversationOp,
} from "./operations";
15 changes: 15 additions & 0 deletions src/lib/db/phone_call/models.ts
Original file line number Diff line number Diff line change
@@ -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;
}
127 changes: 127 additions & 0 deletions src/lib/db/phone_call/operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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<PhoneCall>;
}

/**
* Create a new phone call record.
*/
export async function createPhoneCallOp(
ctx: PhoneCallOperationsContext,
opts: CreatePhoneCallOptions
): Promise<StoredPhoneCall> {
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<StoredPhoneCall | null> {
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<StoredPhoneCall[]> {
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<boolean> {
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;
}

/**
* Delete all phone call records for a conversation.
*/
export async function deletePhoneCallsByConversationOp(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets swap the per-record destroyPermanently() loop for query(...).destroyAllPermanently() — one batched delete inside the write block, no need to materialize the full result set first.

ctx: PhoneCallOperationsContext,
conversationId: string
): Promise<number> {
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;
}
56 changes: 56 additions & 0 deletions src/lib/db/phone_call/types.ts
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The uniqueId field reads a bit ambiguous next to callId. Could we either rename it to id to match StoredMedia / StoredAppFile, or sharpen the comment so its clear this is the Watermelon row id and not the Bland call id?

/** 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;
}
39 changes: 38 additions & 1 deletion src/lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The branch is ~20 commits behind main, and the schema version has moved to v27. This migration targets v19→20, but v20 on main is already the updated_at index migration for memory_vault. We need a rebase so the phone_calls migration lands as v28 and doesn't collide with existing versions.

/**
* Combined WatermelonDB schema for all SDK storage modules.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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" },
],
}),
],
},
],
});

Expand All @@ -505,6 +541,7 @@ export const sdkModelClasses: Class<Model>[] = [
Message,
Conversation,
Project,
PhoneCall,
VaultMemory,
VaultFolder,
Media,
Expand Down
16 changes: 16 additions & 0 deletions src/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,22 @@ 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,
deletePhoneCallsByConversationOp,
} from "../lib/db/phone_call";

// Project storage exports
export {
createProjectOp,
Expand Down
Loading