Skip to content
Merged
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
6 changes: 4 additions & 2 deletions src/domain/ai-drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,17 @@ export class AiDraftService {
.run(nowIso(), draftId);
}

stats(): { pending: number; ready: number; failed: number } {
stats(): { pending: number; ready: number; failed: number; sent: number; discarded: number } {
const rows = this.db
.prepare("SELECT status, COUNT(*) AS cnt FROM ai_drafts GROUP BY status")
.all() as Array<{ status: string; cnt: number }>;
const result = { pending: 0, ready: 0, failed: 0 };
const result = { pending: 0, ready: 0, failed: 0, sent: 0, discarded: 0 };
for (const row of rows) {
if (row.status === "pending") result.pending = row.cnt;
else if (row.status === "ready") result.ready = row.cnt;
else if (row.status === "failed") result.failed = row.cnt;
else if (row.status === "sent") result.sent = row.cnt;
else if (row.status === "discarded") result.discarded = row.cnt;
}
return result;
}
Expand Down
53 changes: 53 additions & 0 deletions src/domain/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,34 @@ export interface ConversationBundle {
conversation: Conversation;
}

export interface ConversationListItem {
id: number;
status: "open" | "closed";
priority: "low" | "normal" | "high" | "urgent";
assignedAdminId: string | null;
createdAt: string;
lastMessageAt: string | null;
contactDisplayName: string | null;
contactUsername: string | null;
topicName: string | null;
messageThreadId: number | null;
}

function conversationListItemFromRow(row: Record<string, unknown>): ConversationListItem {
return {
id: Number(row.id),
status: row.status as ConversationListItem["status"],
priority: row.priority as ConversationListItem["priority"],
assignedAdminId: row.assigned_admin_id === null ? null : String(row.assigned_admin_id),
createdAt: String(row.created_at),
lastMessageAt: row.last_message_at === null ? null : String(row.last_message_at),
contactDisplayName: row.display_name === null ? null : String(row.display_name),
contactUsername: row.username === null ? null : String(row.username),
topicName: row.topic_name === null ? null : String(row.topic_name),
messageThreadId: row.message_thread_id === null ? null : Number(row.message_thread_id),
};
}

export interface AdminNote {
id: number;
conversationId: number;
Expand Down Expand Up @@ -512,4 +540,29 @@ export class ConversationService {
}
return result;
}

listConversations(opts: {
status?: "open" | "closed";
limit: number;
offset: number;
}): { items: ConversationListItem[]; total: number } {
const where = opts.status ? "WHERE c.status = ?" : "";
const params = opts.status ? [opts.status] : [];
const rows = this.db
.prepare(
`SELECT c.id, c.status, c.priority, c.assigned_admin_id, c.created_at, c.last_message_at,
ct.display_name, ct.username, t.topic_name, t.message_thread_id
FROM conversations c
LEFT JOIN contacts ct ON c.contact_id = ct.id
LEFT JOIN telegram_topics t ON t.conversation_id = c.id
${where}
ORDER BY c.last_message_at DESC NULLS LAST
LIMIT ? OFFSET ?`,
)
.all(...params, opts.limit, opts.offset) as Array<Record<string, unknown>>;
const countRow = this.db
.prepare(`SELECT COUNT(*) AS cnt FROM conversations c ${where}`)
.get(...params) as { cnt: number };
return { items: rows.map(conversationListItemFromRow), total: countRow.cnt };
}
}
28 changes: 28 additions & 0 deletions src/domain/deliveries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,32 @@ export class DeliveryService {
}
return result;
}

listFailedDeliveries(opts: {
limit: number;
offset: number;
}): { items: Delivery[]; total: number } {
const rows = this.db
.prepare(
`SELECT * FROM deliveries
WHERE status IN ('failed', 'permanent_failure')
ORDER BY created_at ASC
LIMIT ? OFFSET ?`,
)
.all(opts.limit, opts.offset) as Array<Record<string, unknown>>;
const countRow = this.db
.prepare(`SELECT COUNT(*) AS cnt FROM deliveries WHERE status IN ('failed', 'permanent_failure')`)
.get() as { cnt: number };
return { items: rows.map(deliveryFromRow), total: countRow.cnt };
}

scheduleRetry(deliveryId: number): void {
this.db
.prepare(
`UPDATE deliveries
SET next_retry_at = ?, updated_at = ?
WHERE id = ? AND status = 'failed'`,
)
.run(nowIso(), nowIso(), deliveryId);
}
}
73 changes: 73 additions & 0 deletions src/runtime/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,79 @@ await startWebConsole({
timestamp: new Date().toISOString(),
};
},
collectOperationsOverview: () => {
const config = loadConfigFromSources(settings.all());
const conversations = new ConversationService(
handle.db,
config.MESSAGE_RETENTION_DAYS,
config.DEFAULT_CONVERSATION_RETENTION_DAYS,
);
const deliveries = new DeliveryService(handle.db);
const aiDrafts = new AiDraftService(handle.db, conversations, config);
const msgStats = conversations.messageStats();
const convStats = conversations.conversationStats();
const delStats = deliveries.stats();
const draftStats = aiDrafts.stats();
return {
messages: { inboundTotal: msgStats.inbound, outboundTotal: msgStats.outbound, internalTotal: msgStats.internal },
deliveries: { pending: delStats.pending, sent: delStats.sent, failed: delStats.failed, permanentFailure: delStats.permanentFailure },
conversations: { open: convStats.open, closed: convStats.closed },
aiDrafts: { pending: draftStats.pending, ready: draftStats.ready, failed: draftStats.failed, sent: draftStats.sent, discarded: draftStats.discarded },
uptimeSeconds: Math.round(process.uptime()),
};
},
listConversations: (opts) => {
const conversations = new ConversationService(
handle.db,
loadConfigFromSources(settings.all()).MESSAGE_RETENTION_DAYS,
loadConfigFromSources(settings.all()).DEFAULT_CONVERSATION_RETENTION_DAYS,
);
const result = conversations.listConversations({
status: opts.status === "open" || opts.status === "closed" ? opts.status : undefined,
limit: opts.pageSize,
offset: (opts.page - 1) * opts.pageSize,
});
return {
items: result.items.map((c) => ({
id: c.id,
status: c.status,
priority: c.priority,
assignedAdminId: c.assignedAdminId,
createdAt: c.createdAt,
lastMessageAt: c.lastMessageAt,
contactDisplayName: c.contactDisplayName,
contactUsername: c.contactUsername,
topicName: c.topicName,
messageThreadId: c.messageThreadId,
})),
total: result.total,
};
},
listFailedDeliveries: (opts) => {
const deliveries = new DeliveryService(handle.db);
const result = deliveries.listFailedDeliveries({
limit: opts.pageSize,
offset: (opts.page - 1) * opts.pageSize,
});
return {
items: result.items.map((d) => ({
id: d.id,
sourceMessageId: d.sourceMessageId,
target: d.target,
status: d.status,
attemptCount: d.attemptCount,
lastError: d.lastError,
nextRetryAt: d.nextRetryAt,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
})),
total: result.total,
};
},
scheduleRetry: async (deliveryId: number) => {
const deliveries = new DeliveryService(handle.db);
deliveries.scheduleRetry(deliveryId);
},
});
logger.info({ port: databaseConfig.WEB_CONSOLE_PORT }, "InboxBridge web console started.");
await restartRuntime();
Loading