-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add phone_calls WatermelonDB table #348
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"; |
| 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; | ||
| } |
| 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( | ||
| 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; | ||
| } | ||
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| /** 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; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| /** | ||
| * 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<Model>[] = [ | |
| Message, | ||
| Conversation, | ||
| Project, | ||
| PhoneCall, | ||
| VaultMemory, | ||
| VaultFolder, | ||
| Media, | ||
|
|
||
There was a problem hiding this comment.
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 forquery(...).destroyAllPermanently()— one batched delete inside the write block, no need to materialize the full result set first.