C1 (spec 11): /api/me/contacts and /api/contacts (admin), our own vCard 2.1/3.0/4.0 and CSV readers with a preview, contact_shares (migration 0009) for offers, and contact names in call history, voicemail, Home, the softphone and the call report email. Admin → Contacts.
201 lines
7.5 KiB
TypeScript
201 lines
7.5 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// The call report email (05 §4): each user's calls of the last hour, day or week, with each
|
|
// recorded call's summary if they asked for it. No calls, no email.
|
|
import { and, asc, eq, gte, inArray, lt, ne, sql } from 'drizzle-orm';
|
|
import { alias } from 'drizzle-orm/pg-core';
|
|
import { formatNumber, type CallReport } from '@selfphoneng/shared';
|
|
import { contactNames } from '../contacts/book.ts';
|
|
import type { Db } from '../db/client.ts';
|
|
import { callExtensions, calls, extensions, recordings, users } from '../db/schema.ts';
|
|
import type { SendMail } from './mail.ts';
|
|
import { getSetting } from './settings.ts';
|
|
|
|
/** Calls are written a few seconds after hang-up and summarized a minute or two after. */
|
|
const LAG_MS = 5 * 60_000;
|
|
|
|
/**
|
|
* The last whole period that ended by `now`, in the time zone `tz`: e.g. daily at 08:00 is
|
|
* yesterday 08:00 to today 08:00. Local-time arithmetic in PostgreSQL, so DST days are 23 or 25 h.
|
|
*/
|
|
export async function lastPeriod(
|
|
db: Db,
|
|
tz: string,
|
|
s: { callReport: Exclude<CallReport, 'off'>; callReportTime: string; callReportWeekday: number },
|
|
now: Date,
|
|
): Promise<{ from: Date; to: Date }> {
|
|
const r = await db.execute<{ from: Date; to: Date }>(sql`
|
|
with l as (select (${now.toISOString()}::timestamptz at time zone ${tz}::text) as t),
|
|
s as (
|
|
select t, case ${s.callReport}::text
|
|
when 'hourly' then date_trunc('hour', t)
|
|
when 'daily' then t::date + ${s.callReportTime}::time
|
|
else t::date - ((extract(dow from t)::int - ${s.callReportWeekday}::int + 7) % 7)
|
|
+ ${s.callReportTime}::time
|
|
end as at,
|
|
case ${s.callReport}::text
|
|
when 'hourly' then interval '1 hour'
|
|
when 'daily' then interval '1 day'
|
|
else interval '7 days'
|
|
end as p
|
|
from l),
|
|
e as (select case when at > t then at - p else at end as at, p from s)
|
|
select (at - p) at time zone ${tz}::text as "from", at at time zone ${tz}::text as "to" from e`);
|
|
const row = r.rows[0]!;
|
|
return { from: new Date(row.from), to: new Date(row.to) };
|
|
}
|
|
|
|
const duration = (s: number) =>
|
|
s >= 3600
|
|
? `${Math.floor(s / 3600)}:${String(Math.floor(s / 60) % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
|
: `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
|
/** "102" stays as is; outside numbers get their usual format. */
|
|
const num = (n: string | null) => (n ? (n.length > 6 ? formatNumber(n) : n) : 'unknown');
|
|
|
|
export type ReportCall = {
|
|
startedAt: Date;
|
|
out: boolean;
|
|
status: string;
|
|
fromNumber: string | null;
|
|
fromName: string | null;
|
|
/** The other side's name in my contacts or the company's (11 §1). */
|
|
contactName: string | null;
|
|
toNumber: string | null;
|
|
talkSeconds: number;
|
|
answeredBy: string | null;
|
|
recorded: boolean;
|
|
summary: string | null;
|
|
summaryPending: boolean;
|
|
};
|
|
|
|
/** The email's text: one block per call, oldest first. */
|
|
export function reportText(
|
|
tz: string,
|
|
period: { from: Date; to: Date },
|
|
list: ReportCall[],
|
|
withSummaries: boolean,
|
|
): string {
|
|
const when = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: tz,
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
});
|
|
const lines = [
|
|
`${list.length} call${list.length === 1 ? '' : 's'} from ${when.format(period.from)} to ${when.format(period.to)}:`,
|
|
];
|
|
for (const c of list) {
|
|
const name = c.contactName ?? (c.out ? null : c.fromName);
|
|
const who = c.out
|
|
? `Outgoing to ${num(c.toNumber)}${name ? ` (${name})` : ''}`
|
|
: `Incoming from ${num(c.fromNumber)}${name ? ` (${name})` : ''} to ${num(c.toNumber)}`;
|
|
const facts = [
|
|
c.status === 'answered' && c.answeredBy ? `answered by ${c.answeredBy}` : c.status,
|
|
duration(c.talkSeconds),
|
|
...(c.recorded ? ['recorded'] : []),
|
|
];
|
|
lines.push('', `${when.format(c.startedAt)} ${who}`, ` ${facts.join(' · ')}`);
|
|
if (withSummaries && c.summary)
|
|
lines.push(
|
|
...c.summary
|
|
.trim()
|
|
.split('\n')
|
|
.map((l) => ` ${l}`),
|
|
);
|
|
else if (withSummaries && c.summaryPending)
|
|
lines.push(' Summary not ready yet: see Recordings in the web portal.');
|
|
}
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
|
|
const answerer = alias(extensions, 'answerer');
|
|
|
|
/** One run: every user whose period ended and wasn't reported on yet. */
|
|
export async function sendCallReports(d: {
|
|
db: Db;
|
|
send: SendMail;
|
|
log: { error(o: object, m: string): void };
|
|
now: Date;
|
|
}): Promise<number> {
|
|
const tz = await getSetting(d.db, 'general.timezone');
|
|
const asOf = new Date(d.now.getTime() - LAG_MS);
|
|
const due = await d.db
|
|
.select({ e: extensions, email: users.email })
|
|
.from(extensions)
|
|
.innerJoin(users, eq(users.id, extensions.userId))
|
|
.where(and(ne(extensions.callReport, 'off'), eq(users.isActive, true)));
|
|
let sent = 0;
|
|
for (const { e, email } of due) {
|
|
if (e.callReport === 'off') continue;
|
|
const period = await lastPeriod(d.db, tz, { ...e, callReport: e.callReport }, asOf);
|
|
if (e.callReportSentAt && e.callReportSentAt >= period.to) continue;
|
|
// ponytail: by hang-up time, and only the last period: one that passed while the server
|
|
// was down isn't caught up.
|
|
const rows = await d.db
|
|
.select({
|
|
c: calls,
|
|
answeredBy: answerer.name,
|
|
answeredById: answerer.id,
|
|
summary: recordings.summary,
|
|
aiDoneAt: recordings.aiDoneAt,
|
|
out: sql<boolean>`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id}
|
|
and ${callExtensions.extensionId} = ${e.id} and ${callExtensions.role} = 'caller')`,
|
|
})
|
|
.from(calls)
|
|
.leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
|
|
.leftJoin(recordings, eq(recordings.id, calls.recordingId))
|
|
.where(
|
|
and(
|
|
gte(calls.endedAt, period.from),
|
|
lt(calls.endedAt, period.to),
|
|
inArray(
|
|
calls.id,
|
|
d.db
|
|
.select({ id: callExtensions.callId })
|
|
.from(callExtensions)
|
|
.where(eq(callExtensions.extensionId, e.id)),
|
|
),
|
|
),
|
|
)
|
|
.orderBy(asc(calls.startedAt));
|
|
// Marked first: a failing mail server is logged once, not retried every minute.
|
|
await d.db
|
|
.update(extensions)
|
|
.set({ callReportSentAt: period.to })
|
|
.where(eq(extensions.id, e.id));
|
|
if (!rows.length) continue;
|
|
const names = await contactNames(
|
|
d.db,
|
|
e.userId,
|
|
rows.map((r) => (r.out ? r.c.toNumber : r.c.fromNumber)),
|
|
);
|
|
const list = rows.map((r): ReportCall => ({
|
|
startedAt: r.c.startedAt,
|
|
out: r.out,
|
|
status: r.c.status,
|
|
fromNumber: r.c.fromNumber,
|
|
fromName: r.c.fromName,
|
|
contactName: names.get((r.out ? r.c.toNumber : r.c.fromNumber) ?? '') ?? null,
|
|
toNumber: r.c.toNumber,
|
|
talkSeconds: r.c.talkSeconds,
|
|
answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null,
|
|
recorded: r.c.recordingId !== null,
|
|
summary: r.summary,
|
|
summaryPending: r.c.recordingId !== null && r.aiDoneAt === null,
|
|
}));
|
|
const withSummaries =
|
|
e.callReportSummaries &&
|
|
(await getSetting(d.db, 'ai.callTranscripts')) &&
|
|
(await getSetting(d.db, 'ai.callSummaries'));
|
|
try {
|
|
await d.send({
|
|
to: [email],
|
|
subject: `Your calls (${e.number}): ${list.length} call${list.length === 1 ? '' : 's'}`,
|
|
text: reportText(tz, period, list, withSummaries),
|
|
});
|
|
sent++;
|
|
} catch (err) {
|
|
d.log.error({ err, extension: e.number }, 'call report not sent');
|
|
}
|
|
}
|
|
return sent;
|
|
}
|