feat(conversation-assistant): sync contacts with names from macOS Contacts
- Swift agent: Add Contacts framework integration - Load contacts on startup with phone/email indexing - Look up contact names for conversation display names - Sync contacts to server via /api/sync/contacts - Remove LIMIT 100 on getConversations (fetch all) - Backend: Improve sync data resolution - Auto-create Contact entities from participant identifiers - Store resolved UUIDs in participantIds (not raw phone/email) - Resolve message senderId to Contact UUIDs - Return participants array with conversations - Remove default limits on findAll/getMessages - Add /api/sync/stats endpoint for device stats - Devtools: Add reset-sync-data.sh and show-sync-stats.sh 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4bf0c27b28
commit
511785f381
6 changed files with 443 additions and 22 deletions
|
|
@ -127,6 +127,83 @@ class APIClient {
|
|||
}
|
||||
}
|
||||
|
||||
func getStats() async throws -> SyncStatsResponse {
|
||||
guard let token = authToken else {
|
||||
NSLog("APIClient: getStats - no auth token!")
|
||||
throw APIError.notAuthenticated
|
||||
}
|
||||
|
||||
let headers: HTTPHeaders = [
|
||||
"Authorization": "Bearer \(token)"
|
||||
]
|
||||
|
||||
let url = "\(baseURL)/api/sync/stats"
|
||||
NSLog("APIClient: getStats - calling \(url)")
|
||||
|
||||
let response = try await AF.request(
|
||||
url,
|
||||
method: .get,
|
||||
headers: headers
|
||||
).serializingData().value
|
||||
|
||||
let json = try JSON(data: response)
|
||||
NSLog("APIClient: getStats - response: \(json)")
|
||||
|
||||
guard json["success"].boolValue else {
|
||||
let errorMsg = json["error"]["message"].stringValue
|
||||
throw APIError.requestFailed(errorMsg.isEmpty ? "Unknown API error" : errorMsg)
|
||||
}
|
||||
|
||||
let lastSyncAt: Date?
|
||||
if let dateString = json["data"]["lastSyncAt"].string {
|
||||
lastSyncAt = ISO8601DateFormatter().date(from: dateString)
|
||||
} else {
|
||||
lastSyncAt = nil
|
||||
}
|
||||
|
||||
return SyncStatsResponse(
|
||||
totalMessages: json["data"]["totalMessages"].intValue,
|
||||
totalConversations: json["data"]["totalConversations"].intValue,
|
||||
lastSyncAt: lastSyncAt
|
||||
)
|
||||
}
|
||||
|
||||
func syncContacts(_ contacts: [SyncContactPayload]) async throws -> Int {
|
||||
guard let token = authToken else {
|
||||
NSLog("APIClient: syncContacts - no auth token!")
|
||||
throw APIError.notAuthenticated
|
||||
}
|
||||
|
||||
let headers: HTTPHeaders = [
|
||||
"Authorization": "Bearer \(token)"
|
||||
]
|
||||
|
||||
let url = "\(baseURL)/api/sync/contacts"
|
||||
let params: [String: Any] = [
|
||||
"contacts": contacts.map { $0.dictionary }
|
||||
]
|
||||
|
||||
NSLog("APIClient: syncContacts - calling \(url) with \(contacts.count) contacts")
|
||||
|
||||
let response = try await AF.request(
|
||||
url,
|
||||
method: .post,
|
||||
parameters: params,
|
||||
encoding: JSONEncoding.default,
|
||||
headers: headers
|
||||
).serializingData().value
|
||||
|
||||
let json = try JSON(data: response)
|
||||
NSLog("APIClient: syncContacts - response: \(json)")
|
||||
|
||||
guard json["success"].boolValue else {
|
||||
let errorMsg = json["error"]["message"].stringValue
|
||||
throw APIError.requestFailed(errorMsg.isEmpty ? "Unknown API error" : errorMsg)
|
||||
}
|
||||
|
||||
return json["data"]["synced"].intValue
|
||||
}
|
||||
|
||||
private func getHardwareId() -> String {
|
||||
// Get the hardware UUID
|
||||
let service = IOServiceGetMatchingService(
|
||||
|
|
@ -195,6 +272,26 @@ struct SyncMessagePayload: Encodable {
|
|||
}
|
||||
}
|
||||
|
||||
struct SyncStatsResponse {
|
||||
let totalMessages: Int
|
||||
let totalConversations: Int
|
||||
let lastSyncAt: Date?
|
||||
}
|
||||
|
||||
struct SyncContactPayload: Encodable {
|
||||
let phoneNumber: String?
|
||||
let email: String?
|
||||
let displayName: String
|
||||
|
||||
var dictionary: [String: Any?] {
|
||||
return [
|
||||
"phoneNumber": phoneNumber,
|
||||
"email": email,
|
||||
"displayName": displayName
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
enum APIError: LocalizedError {
|
||||
case notAuthenticated
|
||||
case requestFailed(String)
|
||||
|
|
|
|||
|
|
@ -68,8 +68,22 @@ class SyncManager: ObservableObject {
|
|||
return
|
||||
}
|
||||
|
||||
// Initial sync
|
||||
syncNow()
|
||||
// Load contacts for name resolution
|
||||
Task {
|
||||
let contactsLoaded = await imessageReader.loadContacts()
|
||||
NSLog("SyncManager: Contacts loaded: \(contactsLoaded)")
|
||||
|
||||
// Sync contacts to server
|
||||
if contactsLoaded {
|
||||
await syncContacts()
|
||||
}
|
||||
|
||||
// Fetch stats from server on startup to show accurate totals
|
||||
await fetchStats()
|
||||
|
||||
// Initial sync (now with contact names available)
|
||||
await performSync()
|
||||
}
|
||||
|
||||
// Schedule periodic sync every 30 seconds
|
||||
syncTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
|
||||
|
|
@ -146,19 +160,14 @@ class SyncManager: ObservableObject {
|
|||
NSLog("SyncManager: Synced \(synced) messages")
|
||||
}
|
||||
|
||||
// Replace entire struct to trigger @Published notification
|
||||
let newStats = SyncStats(
|
||||
messageCount: stats.messageCount + totalSynced,
|
||||
conversationCount: conversations.count
|
||||
)
|
||||
stats = newStats
|
||||
NSLog("SyncManager: Stats updated - messages: \(newStats.messageCount), conversations: \(newStats.conversationCount)")
|
||||
|
||||
let newSyncTime = Date()
|
||||
lastSync = newSyncTime
|
||||
UserDefaults.standard.set(newSyncTime, forKey: "lastSync")
|
||||
NSLog("SyncManager: Sync complete - \(totalSynced) new messages synced, lastSync set to \(newSyncTime)")
|
||||
|
||||
// Fetch accurate stats from server after sync
|
||||
await fetchStats()
|
||||
|
||||
} catch {
|
||||
NSLog("SyncManager: Sync failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
|
@ -166,4 +175,52 @@ class SyncManager: ObservableObject {
|
|||
isSyncing = false
|
||||
NSLog("SyncManager: performSync finished, isSyncing = false")
|
||||
}
|
||||
|
||||
private func syncContacts() async {
|
||||
NSLog("SyncManager: syncContacts starting")
|
||||
do {
|
||||
let contacts = imessageReader.getAllContacts()
|
||||
guard !contacts.isEmpty else {
|
||||
NSLog("SyncManager: No contacts to sync")
|
||||
return
|
||||
}
|
||||
|
||||
let payloads = contacts.map { contact in
|
||||
SyncContactPayload(
|
||||
phoneNumber: contact.phoneNumber,
|
||||
email: contact.email,
|
||||
displayName: contact.displayName
|
||||
)
|
||||
}
|
||||
|
||||
let synced = try await apiClient.syncContacts(payloads)
|
||||
NSLog("SyncManager: Synced \(synced) contacts to server")
|
||||
} catch {
|
||||
NSLog("SyncManager: syncContacts failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchStats() async {
|
||||
NSLog("SyncManager: fetchStats starting")
|
||||
do {
|
||||
let response = try await apiClient.getStats()
|
||||
NSLog("SyncManager: fetchStats - got totalMessages: \(response.totalMessages), totalConversations: \(response.totalConversations)")
|
||||
|
||||
// Update stats from server response
|
||||
let newStats = SyncStats(
|
||||
messageCount: response.totalMessages,
|
||||
conversationCount: response.totalConversations
|
||||
)
|
||||
stats = newStats
|
||||
|
||||
// Update lastSync from server if we don't have a local value
|
||||
if let serverLastSync = response.lastSyncAt, lastSync == nil {
|
||||
lastSync = serverLastSync
|
||||
UserDefaults.standard.set(serverLastSync, forKey: "lastSync")
|
||||
NSLog("SyncManager: Updated lastSync from server: \(serverLastSync)")
|
||||
}
|
||||
} catch {
|
||||
NSLog("SyncManager: fetchStats failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Foundation
|
||||
import GRDB
|
||||
import Contacts
|
||||
|
||||
struct iMessageConversation {
|
||||
let id: String
|
||||
|
|
@ -8,6 +9,13 @@ struct iMessageConversation {
|
|||
let participantIds: [String]
|
||||
}
|
||||
|
||||
struct ContactInfo {
|
||||
let identifier: String // phone or email
|
||||
let displayName: String
|
||||
let phoneNumber: String?
|
||||
let email: String?
|
||||
}
|
||||
|
||||
struct iMessage {
|
||||
let guid: String
|
||||
let conversationId: String
|
||||
|
|
@ -25,6 +33,8 @@ class iMessageReader {
|
|||
|
||||
private var dbQueue: DatabaseQueue?
|
||||
private let chatDbPath: String
|
||||
private let contactStore = CNContactStore()
|
||||
private var contactCache: [String: ContactInfo] = [:]
|
||||
|
||||
private init() {
|
||||
// iMessage database location
|
||||
|
|
@ -32,6 +42,100 @@ class iMessageReader {
|
|||
chatDbPath = homeDir.appendingPathComponent("Library/Messages/chat.db").path
|
||||
}
|
||||
|
||||
/// Request contacts permission and load contact cache
|
||||
func loadContacts() async -> Bool {
|
||||
do {
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
if status == .notDetermined {
|
||||
let granted = try await contactStore.requestAccess(for: .contacts)
|
||||
if !granted { return false }
|
||||
} else if status != .authorized {
|
||||
return false
|
||||
}
|
||||
|
||||
// Fetch all contacts with phone numbers and emails
|
||||
let keysToFetch: [CNKeyDescriptor] = [
|
||||
CNContactGivenNameKey as CNKeyDescriptor,
|
||||
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||
CNContactPhoneNumbersKey as CNKeyDescriptor,
|
||||
CNContactEmailAddressesKey as CNKeyDescriptor
|
||||
]
|
||||
|
||||
let request = CNContactFetchRequest(keysToFetch: keysToFetch)
|
||||
try contactStore.enumerateContacts(with: request) { contact, _ in
|
||||
let fullName = [contact.givenName, contact.familyName]
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
|
||||
guard !fullName.isEmpty else { return }
|
||||
|
||||
// Index by phone numbers
|
||||
for phone in contact.phoneNumbers {
|
||||
let normalized = self.normalizePhoneNumber(phone.value.stringValue)
|
||||
self.contactCache[normalized] = ContactInfo(
|
||||
identifier: normalized,
|
||||
displayName: fullName,
|
||||
phoneNumber: normalized,
|
||||
email: nil
|
||||
)
|
||||
}
|
||||
|
||||
// Index by emails
|
||||
for email in contact.emailAddresses {
|
||||
let emailStr = email.value as String
|
||||
self.contactCache[emailStr.lowercased()] = ContactInfo(
|
||||
identifier: emailStr,
|
||||
displayName: fullName,
|
||||
phoneNumber: nil,
|
||||
email: emailStr
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NSLog("iMessageReader: Loaded \(contactCache.count) contact entries")
|
||||
return true
|
||||
} catch {
|
||||
NSLog("iMessageReader: Failed to load contacts: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a contact by phone number or email
|
||||
func lookupContact(identifier: String) -> ContactInfo? {
|
||||
// Try direct lookup
|
||||
if let contact = contactCache[identifier.lowercased()] {
|
||||
return contact
|
||||
}
|
||||
|
||||
// Try normalized phone lookup
|
||||
let normalized = normalizePhoneNumber(identifier)
|
||||
return contactCache[normalized]
|
||||
}
|
||||
|
||||
/// Get all cached contacts for syncing
|
||||
func getAllContacts() -> [ContactInfo] {
|
||||
// Deduplicate by display name (same person may have multiple phones/emails)
|
||||
var seen = Set<String>()
|
||||
return contactCache.values.filter { contact in
|
||||
if seen.contains(contact.displayName) { return false }
|
||||
seen.insert(contact.displayName)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizePhoneNumber(_ phone: String) -> String {
|
||||
// Keep only digits and leading +
|
||||
let digits = phone.filter { $0.isNumber || $0 == "+" }
|
||||
// Remove leading 1 for US numbers if no + prefix
|
||||
if digits.hasPrefix("1") && !digits.hasPrefix("+") && digits.count == 11 {
|
||||
return "+" + digits
|
||||
}
|
||||
if !digits.hasPrefix("+") && digits.count == 10 {
|
||||
return "+1" + digits
|
||||
}
|
||||
return digits
|
||||
}
|
||||
|
||||
func connect() throws {
|
||||
guard FileManager.default.fileExists(atPath: chatDbPath) else {
|
||||
throw iMessageError.databaseNotFound
|
||||
|
|
@ -49,6 +153,7 @@ class iMessageReader {
|
|||
}
|
||||
|
||||
return try db.read { db in
|
||||
// No LIMIT - fetch all conversations
|
||||
let sql = """
|
||||
SELECT
|
||||
c.guid,
|
||||
|
|
@ -60,16 +165,27 @@ class iMessageReader {
|
|||
LEFT JOIN handle h ON chj.handle_id = h.ROWID
|
||||
GROUP BY c.ROWID
|
||||
ORDER BY c.ROWID DESC
|
||||
LIMIT 100
|
||||
"""
|
||||
|
||||
return try Row.fetchAll(db, sql: sql).map { row in
|
||||
let participantStr = row["participant_ids"] as? String ?? ""
|
||||
let participants = participantStr.split(separator: ",").map(String.init)
|
||||
|
||||
// Try to get display name from contacts if not set
|
||||
var displayName: String = row["display_name"] ?? ""
|
||||
if displayName.isEmpty && participants.count == 1 {
|
||||
if let contact = lookupContact(identifier: participants[0]) {
|
||||
displayName = contact.displayName
|
||||
}
|
||||
}
|
||||
if displayName.isEmpty {
|
||||
// Fall back to first participant or "Unknown"
|
||||
displayName = participants.first ?? "Unknown"
|
||||
}
|
||||
|
||||
return iMessageConversation(
|
||||
id: row["guid"],
|
||||
displayName: row["display_name"] ?? "Unknown",
|
||||
displayName: displayName,
|
||||
isGroup: row["group_id"] != nil,
|
||||
participantIds: participants
|
||||
)
|
||||
|
|
@ -90,6 +206,7 @@ class iMessageReader {
|
|||
h.id as sender_id,
|
||||
m.is_from_me,
|
||||
m.text,
|
||||
m.attributedBody,
|
||||
a.filename as attachment_path,
|
||||
m.date / 1000000000 + 978307200 as date_unix,
|
||||
m.date_delivered / 1000000000 + 978307200 as date_delivered_unix,
|
||||
|
|
@ -118,12 +235,20 @@ class iMessageReader {
|
|||
let deliveredUnix: TimeInterval? = row["date_delivered_unix"]
|
||||
let readUnix: TimeInterval? = row["date_read_unix"]
|
||||
|
||||
// Try plain text first, then extract from attributedBody if needed
|
||||
var messageText: String? = row["text"]
|
||||
if messageText == nil || messageText?.isEmpty == true {
|
||||
if let attributedBodyData: Data = row["attributedBody"] {
|
||||
messageText = extractTextFromAttributedBody(attributedBodyData)
|
||||
}
|
||||
}
|
||||
|
||||
return iMessage(
|
||||
guid: row["guid"],
|
||||
conversationId: row["conversation_id"],
|
||||
senderId: row["sender_id"],
|
||||
isFromMe: row["is_from_me"] == 1,
|
||||
text: row["text"],
|
||||
text: messageText,
|
||||
attachmentPath: row["attachment_path"],
|
||||
date: Date(timeIntervalSince1970: dateUnix),
|
||||
dateDelivered: deliveredUnix.map { Date(timeIntervalSince1970: $0) },
|
||||
|
|
@ -132,6 +257,90 @@ class iMessageReader {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract plain text from iMessage's attributedBody binary format
|
||||
/// The attributedBody is a "typedstream" archive containing NSAttributedString
|
||||
private func extractTextFromAttributedBody(_ data: Data) -> String? {
|
||||
guard data.count > 0 else { return nil }
|
||||
|
||||
// iMessage's attributedBody uses Apple's typedstream format
|
||||
// The text content is embedded as a length-prefixed UTF-8 string after a marker
|
||||
|
||||
// Look for the NSString content - it's typically stored after "NSString" class marker
|
||||
// followed by a length byte and the UTF-8 text
|
||||
let bytes = [UInt8](data)
|
||||
|
||||
// Strategy 1: Find text after the "streamtyped" header
|
||||
// The header is followed by object definitions, then the actual string data
|
||||
// Look for patterns that indicate string length followed by string data
|
||||
|
||||
// Find sequences of printable characters (the actual message text)
|
||||
var longestText: String?
|
||||
var currentRun = Data()
|
||||
|
||||
for byte in bytes {
|
||||
// Check if byte is printable ASCII or UTF-8 continuation
|
||||
if (byte >= 0x20 && byte <= 0x7E) || byte >= 0x80 {
|
||||
currentRun.append(byte)
|
||||
} else if byte == 0x0A || byte == 0x0D { // newlines are OK
|
||||
currentRun.append(byte)
|
||||
} else {
|
||||
// End of a potential string run
|
||||
if currentRun.count > 3 {
|
||||
if let str = String(data: currentRun, encoding: .utf8),
|
||||
str.rangeOfCharacter(from: .letters) != nil {
|
||||
// Must contain at least one letter to be valid text
|
||||
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.count > (longestText?.count ?? 0) {
|
||||
// Skip strings that look like class names or metadata
|
||||
if !trimmed.hasPrefix("NS") &&
|
||||
!trimmed.hasPrefix("__") &&
|
||||
!trimmed.contains("attributedString") {
|
||||
longestText = trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
currentRun = Data()
|
||||
}
|
||||
}
|
||||
|
||||
// Check final run
|
||||
if currentRun.count > 3 {
|
||||
if let str = String(data: currentRun, encoding: .utf8),
|
||||
str.rangeOfCharacter(from: .letters) != nil {
|
||||
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.count > (longestText?.count ?? 0) &&
|
||||
!trimmed.hasPrefix("NS") &&
|
||||
!trimmed.hasPrefix("__") &&
|
||||
!trimmed.contains("attributedString") {
|
||||
longestText = trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: If the above didn't find good text, try NSKeyedUnarchiver
|
||||
if longestText == nil || longestText!.isEmpty {
|
||||
if #available(macOS 10.13, *) {
|
||||
do {
|
||||
let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
|
||||
unarchiver.requiresSecureCoding = false
|
||||
|
||||
if let attrString = unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? NSAttributedString {
|
||||
let text = attrString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !text.isEmpty {
|
||||
return text
|
||||
}
|
||||
}
|
||||
unarchiver.finishDecoding()
|
||||
} catch {
|
||||
// Expected to fail for typedstream format, not NSKeyedArchiver format
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return longestText
|
||||
}
|
||||
}
|
||||
|
||||
enum iMessageError: LocalizedError {
|
||||
|
|
|
|||
|
|
@ -42,13 +42,10 @@ export class ConversationsService {
|
|||
}
|
||||
|
||||
async findAll(params?: { limit?: number; offset?: number }): Promise<ConversationWithParticipants[]> {
|
||||
const limit = params?.limit ?? 50;
|
||||
const offset = params?.offset ?? 0;
|
||||
|
||||
const conversations = await this.conversationRepository.find({
|
||||
order: { lastMessageAt: 'DESC' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
...(params?.limit && { take: params.limit }),
|
||||
...(params?.offset && { skip: params.offset }),
|
||||
});
|
||||
|
||||
// Batch resolve all participants for efficiency
|
||||
|
|
@ -87,13 +84,14 @@ export class ConversationsService {
|
|||
conversationId: string,
|
||||
params?: { limit?: number; before?: string },
|
||||
): Promise<MessageEntity[]> {
|
||||
const limit = params?.limit ?? 50;
|
||||
|
||||
const queryBuilder = this.messageRepository
|
||||
.createQueryBuilder('message')
|
||||
.where('message.conversationId = :conversationId', { conversationId })
|
||||
.orderBy('message.sentAt', 'DESC')
|
||||
.take(limit);
|
||||
.orderBy('message.sentAt', 'DESC');
|
||||
|
||||
if (params?.limit) {
|
||||
queryBuilder.take(params.limit);
|
||||
}
|
||||
|
||||
if (params?.before) {
|
||||
const beforeDate = new Date(params.before);
|
||||
|
|
|
|||
|
|
@ -99,4 +99,29 @@ export class SyncController {
|
|||
data: { lastSyncAt: lastSyncAt?.toISOString() ?? null },
|
||||
};
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
@ApiOperation({ summary: 'Get sync statistics for the authenticated device' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Sync statistics retrieved successfully',
|
||||
schema: {
|
||||
example: {
|
||||
success: true,
|
||||
data: {
|
||||
totalMessages: 1250,
|
||||
totalConversations: 45,
|
||||
lastSyncAt: '2024-01-01T12:00:00Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized - invalid or missing token' })
|
||||
async getStats(@CurrentDevice() device: JwtPayload) {
|
||||
const stats = await this.syncService.getStats(device.deviceId);
|
||||
return {
|
||||
success: true,
|
||||
data: stats,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -246,4 +246,39 @@ export class SyncService {
|
|||
|
||||
return conversation?.lastMessageAt ?? null;
|
||||
}
|
||||
|
||||
async getStats(deviceId: string): Promise<{
|
||||
totalMessages: number;
|
||||
totalConversations: number;
|
||||
lastSyncAt: string | null;
|
||||
}> {
|
||||
// Count conversations for this device
|
||||
const totalConversations = await this.conversationRepository.count({
|
||||
where: { deviceId },
|
||||
});
|
||||
|
||||
// Count total messages across all conversations for this device
|
||||
const conversations = await this.conversationRepository.find({
|
||||
where: { deviceId },
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
let totalMessages = 0;
|
||||
if (conversations.length > 0) {
|
||||
const conversationIds = conversations.map((c) => c.id);
|
||||
totalMessages = await this.messageRepository
|
||||
.createQueryBuilder('message')
|
||||
.where('message.conversationId IN (:...ids)', { ids: conversationIds })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
// Get last sync time
|
||||
const lastSync = await this.getLastSync(deviceId);
|
||||
|
||||
return {
|
||||
totalMessages,
|
||||
totalConversations,
|
||||
lastSyncAt: lastSync?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue