causly-server 0.0.0-stage → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +120 -0
- package/CHANGELOG.md +126 -0
- package/LICENSE +21 -0
- package/README.md +377 -3
- package/bin/causly.js +192 -0
- package/index.js +229 -0
- package/package.json +59 -6
- package/packs/calendar.js +141 -0
- package/packs/command.js +22 -0
- package/packs/database.js +95 -0
- package/packs/directory.js +63 -0
- package/packs/discord.js +63 -0
- package/packs/docker.js +202 -0
- package/packs/files.js +106 -0
- package/packs/git.js +211 -0
- package/packs/github.js +321 -0
- package/packs/gmail.js +98 -0
- package/packs/leads.js +46 -0
- package/packs/linear.js +104 -0
- package/packs/loader.js +247 -0
- package/packs/manifest.json +1152 -0
- package/packs/meetings.js +141 -0
- package/packs/notion.js +185 -0
- package/packs/pipeline.js +151 -0
- package/packs/project.js +70 -0
- package/packs/registry.js +149 -0
- package/packs/reminders.js +57 -0
- package/packs/research.js +34 -0
- package/packs/secrets.js +77 -0
- package/packs/sentry.js +97 -0
- package/packs/seo.js +70 -0
- package/packs/slack.js +95 -0
- package/packs/stripe.js +127 -0
- package/packs/supabase.js +73 -0
- package/packs/telegram.js +70 -0
- package/packs/terraform.js +247 -0
- package/packs/vercel.js +114 -0
- package/packs/workflow.js +84 -0
- package/prompts/cofounder.js +66 -0
- package/prompts/playbooks.js +75 -0
- package/tools/backupOps.js +87 -0
- package/tools/boundaryAttest.js +184 -0
- package/tools/calendarOps.js +563 -0
- package/tools/cliOps.js +233 -0
- package/tools/commandOps.js +198 -0
- package/tools/dbOps.js +117 -0
- package/tools/directoryOps.js +96 -0
- package/tools/discordOps.js +133 -0
- package/tools/dockerOps.js +259 -0
- package/tools/envFile.js +89 -0
- package/tools/envLoader.js +85 -0
- package/tools/fileOps.js +125 -0
- package/tools/gitOps.js +170 -0
- package/tools/githubOps.js +357 -0
- package/tools/gmailOps.js +221 -0
- package/tools/googleAuth.js +51 -0
- package/tools/googleLogin.js +230 -0
- package/tools/leadOps.js +447 -0
- package/tools/linearOps.js +304 -0
- package/tools/logger.js +47 -0
- package/tools/meetingOps.js +697 -0
- package/tools/notionOps.js +215 -0
- package/tools/paths.js +36 -0
- package/tools/pipelineOps.js +511 -0
- package/tools/projectOps.js +192 -0
- package/tools/reminderAlerts.js +48 -0
- package/tools/reminderOps.js +129 -0
- package/tools/scheduler.js +46 -0
- package/tools/secretsOps.js +133 -0
- package/tools/security.js +488 -0
- package/tools/sentryOps.js +130 -0
- package/tools/seoOps.js +561 -0
- package/tools/services.js +295 -0
- package/tools/setupOps.js +245 -0
- package/tools/slackOps.js +151 -0
- package/tools/statusOps.js +104 -0
- package/tools/store.js +111 -0
- package/tools/stripeOps.js +299 -0
- package/tools/supabaseOps.js +94 -0
- package/tools/telegramOps.js +153 -0
- package/tools/terminal.js +124 -0
- package/tools/terraformOps.js +171 -0
- package/tools/toolWrapper.js +60 -0
- package/tools/toolsetOps.js +123 -0
- package/tools/transcribeOps.js +221 -0
- package/tools/updateCheck.js +58 -0
- package/tools/vercelOps.js +198 -0
- package/tools/verifyBoundaryAttest.js +25 -0
- package/tools/webOps.js +474 -0
- package/tools/workflowOps.js +371 -0
- package/tools/workspace.js +85 -0
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
// Meeting notes, stored locally in SQLite (~/.causly/causly.db).
|
|
2
|
+
//
|
|
3
|
+
// meeting_transcribe recording -> transcript, saved as a draft meeting (audio goes to Groq)
|
|
4
|
+
// meeting_save the agent's summary, decisions and action items (or a pasted/file transcript)
|
|
5
|
+
// meeting_list / _get browse meetings
|
|
6
|
+
// meeting_search find what was said, across every meeting
|
|
7
|
+
// meeting_actions open action items, overdue first
|
|
8
|
+
// meeting_complete_action tick an action item off
|
|
9
|
+
// meeting_prep brief before a call: calendar event, pipeline deal, past meetings, open items
|
|
10
|
+
// meeting_delete remove a meeting and its transcript
|
|
11
|
+
//
|
|
12
|
+
// The server has no language model: the connected agent reads the transcript and writes the
|
|
13
|
+
// summary; this module stores it, makes it searchable, schedules reminders for action items
|
|
14
|
+
// with due dates, and links the meeting to a pipeline deal and a calendar event.
|
|
15
|
+
import path from "path";
|
|
16
|
+
import { getDb, registerMigrations } from "./store.js";
|
|
17
|
+
import { reminderCreate, reminderUpdate } from "./reminderOps.js";
|
|
18
|
+
import { getDeal, listDeals, logActivity } from "./pipelineOps.js";
|
|
19
|
+
import { listEvents, getEvent, zonedToInstant, zonedParts, defaultTimezone } from "./calendarOps.js";
|
|
20
|
+
import { transcribeAudio, parseTranscriptFile } from "./transcribeOps.js";
|
|
21
|
+
import { UNTRUSTED_NOTICE } from "./webOps.js";
|
|
22
|
+
|
|
23
|
+
registerMigrations("meetings", [
|
|
24
|
+
`CREATE TABLE meetings (
|
|
25
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
26
|
+
title TEXT NOT NULL,
|
|
27
|
+
status TEXT NOT NULL DEFAULT 'draft',
|
|
28
|
+
started_at TEXT NOT NULL,
|
|
29
|
+
duration_minutes INTEGER,
|
|
30
|
+
attendees TEXT,
|
|
31
|
+
source TEXT NOT NULL,
|
|
32
|
+
source_file TEXT,
|
|
33
|
+
language TEXT,
|
|
34
|
+
transcript TEXT,
|
|
35
|
+
summary TEXT,
|
|
36
|
+
decisions TEXT,
|
|
37
|
+
calendar_event_id TEXT,
|
|
38
|
+
deal_id INTEGER,
|
|
39
|
+
deal_logged_at TEXT,
|
|
40
|
+
created_at TEXT NOT NULL,
|
|
41
|
+
updated_at TEXT NOT NULL
|
|
42
|
+
)`,
|
|
43
|
+
`CREATE TABLE action_items (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
meeting_id INTEGER NOT NULL REFERENCES meetings (id) ON DELETE CASCADE,
|
|
46
|
+
text TEXT NOT NULL,
|
|
47
|
+
owner TEXT,
|
|
48
|
+
due_date TEXT,
|
|
49
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
50
|
+
reminder_id INTEGER,
|
|
51
|
+
created_at TEXT NOT NULL,
|
|
52
|
+
completed_at TEXT
|
|
53
|
+
)`,
|
|
54
|
+
`CREATE INDEX idx_meetings_started ON meetings (started_at)`,
|
|
55
|
+
`CREATE INDEX idx_meetings_deal ON meetings (deal_id)`,
|
|
56
|
+
`CREATE INDEX idx_actions_status_due ON action_items (status, due_date)`,
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const MIN = 60_000;
|
|
60
|
+
const clamp = (n, lo, hi) => Math.min(Math.max(n, lo), hi);
|
|
61
|
+
const iso = (ms = Date.now()) => new Date(ms).toISOString();
|
|
62
|
+
const parseJson = (s, fallback) => {
|
|
63
|
+
try {
|
|
64
|
+
return s ? JSON.parse(s) : fallback;
|
|
65
|
+
} catch {
|
|
66
|
+
return fallback;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== null && v !== undefined));
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------- validation
|
|
72
|
+
|
|
73
|
+
function cleanText(v, label, max) {
|
|
74
|
+
if (typeof v !== "string") throw new Error(`${label} must be text.`);
|
|
75
|
+
const s = v.trim();
|
|
76
|
+
if (!s) throw new Error(`${label} cannot be empty.`);
|
|
77
|
+
if (s.length > max) throw new Error(`${label} is too long (${max} characters at most).`);
|
|
78
|
+
return s;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function cleanList(v, label, maxItems, maxLen) {
|
|
82
|
+
if (!Array.isArray(v)) throw new Error(`${label} must be a list.`);
|
|
83
|
+
if (v.length > maxItems) throw new Error(`${label} has too many items (${maxItems} at most).`);
|
|
84
|
+
return v.map((x, i) => cleanText(x, `${label}[${i + 1}]`, maxLen));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function cleanAttendees(v) {
|
|
88
|
+
if (!Array.isArray(v)) throw new Error("attendees must be a list of emails or names.");
|
|
89
|
+
const out = [...new Set(v.map((x) => String(x ?? "").trim()).filter(Boolean).map((x) => (x.includes("@") ? x.toLowerCase() : x)))];
|
|
90
|
+
if (out.length > 50) throw new Error("attendees has too many entries (50 at most).");
|
|
91
|
+
if (out.some((x) => x.length > 200)) throw new Error("An attendee entry is too long.");
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkDate(v, label) {
|
|
96
|
+
const s = String(v ?? "").trim();
|
|
97
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(s) || Number.isNaN(Date.parse(s))) throw new Error(`${label} must look like 2026-10-31.`);
|
|
98
|
+
return s;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function cleanActions(items) {
|
|
102
|
+
if (!Array.isArray(items)) throw new Error("action_items must be a list of { text, owner, due_date }.");
|
|
103
|
+
if (items.length > 50) throw new Error("action_items has too many entries (50 at most).");
|
|
104
|
+
return items.map((it, i) => {
|
|
105
|
+
const o = typeof it === "string" ? { text: it } : it ?? {};
|
|
106
|
+
return {
|
|
107
|
+
text: cleanText(o.text, `action_items[${i + 1}].text`, 500),
|
|
108
|
+
owner: o.owner ? cleanText(o.owner, `action_items[${i + 1}].owner`, 100) : null,
|
|
109
|
+
due_date: o.due_date ? checkDate(o.due_date, `action_items[${i + 1}].due_date`) : null,
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function checkInt(v, label) {
|
|
115
|
+
if (!Number.isInteger(v)) throw new Error(`${label} must be a whole number.`);
|
|
116
|
+
return v;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const parseDate = (v, label) => {
|
|
120
|
+
const ms = Date.parse(String(v));
|
|
121
|
+
if (Number.isNaN(ms)) throw new Error(`${label} must be a date-time like 2026-10-23T15:00:00+05:30.`);
|
|
122
|
+
return ms;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const todayIn = (tz, now = Date.now()) => {
|
|
126
|
+
const p = zonedParts(now, tz);
|
|
127
|
+
return `${p.y}-${String(p.m).padStart(2, "0")}-${String(p.d).padStart(2, "0")}`;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** 09:00 on the due date, in the user's timezone, as a UTC timestamp. */
|
|
131
|
+
function dueAtFor(dueDate, tz) {
|
|
132
|
+
const [y, m, d] = dueDate.split("-").map(Number);
|
|
133
|
+
return new Date(zonedToInstant({ y, m, d, hh: 9, mm: 0 }, tz)).toISOString();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------- shaping
|
|
137
|
+
|
|
138
|
+
function itemsFor(db, meetingId, tz) {
|
|
139
|
+
const today = todayIn(tz);
|
|
140
|
+
return db
|
|
141
|
+
.prepare("SELECT id, text, owner, due_date, status, completed_at FROM action_items WHERE meeting_id = ? ORDER BY id")
|
|
142
|
+
.all(meetingId)
|
|
143
|
+
.map((a) => compact({ ...a, overdue: a.status === "open" && a.due_date && a.due_date < today ? true : undefined }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function view(row) {
|
|
147
|
+
return compact({
|
|
148
|
+
id: row.id,
|
|
149
|
+
title: row.title,
|
|
150
|
+
status: row.status,
|
|
151
|
+
started_at: row.started_at,
|
|
152
|
+
duration_minutes: row.duration_minutes,
|
|
153
|
+
attendees: parseJson(row.attendees, []),
|
|
154
|
+
source: row.source,
|
|
155
|
+
source_file: row.source_file,
|
|
156
|
+
language: row.language,
|
|
157
|
+
summary: row.summary,
|
|
158
|
+
decisions: parseJson(row.decisions, []),
|
|
159
|
+
calendar_event_id: row.calendar_event_id,
|
|
160
|
+
deal_id: row.deal_id,
|
|
161
|
+
transcript_chars: row.transcript ? row.transcript.length : 0,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const titleFromFile = (file) =>
|
|
166
|
+
path
|
|
167
|
+
.basename(file, path.extname(file))
|
|
168
|
+
.replace(/[_\-.]+/g, " ")
|
|
169
|
+
.replace(/\s+/g, " ")
|
|
170
|
+
.trim() || "Meeting";
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------- calendar matching
|
|
173
|
+
|
|
174
|
+
/** Events that overlap the recording window by at least half of the shorter of the two. */
|
|
175
|
+
export function bestEventMatches(events, startMs, endMs) {
|
|
176
|
+
const win = endMs - startMs;
|
|
177
|
+
return events
|
|
178
|
+
.filter((e) => !e.all_day && e.status !== "cancelled" && e.start && e.end)
|
|
179
|
+
.map((e) => {
|
|
180
|
+
const s = Date.parse(e.start);
|
|
181
|
+
const en = Date.parse(e.end);
|
|
182
|
+
const overlap = Math.min(en, endMs) - Math.max(s, startMs);
|
|
183
|
+
const shorter = Math.min(en - s, win);
|
|
184
|
+
return { e, score: shorter > 0 ? overlap / shorter : 0 };
|
|
185
|
+
})
|
|
186
|
+
.filter((x) => x.score >= 0.5)
|
|
187
|
+
.sort((a, b) => b.score - a.score)
|
|
188
|
+
.slice(0, 3)
|
|
189
|
+
.map(({ e }) => ({
|
|
190
|
+
event_id: e.id,
|
|
191
|
+
title: e.title,
|
|
192
|
+
start: e.start,
|
|
193
|
+
end: e.end,
|
|
194
|
+
attendees: (e.attendees || []).filter((a) => !a.self).map((a) => a.email),
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function calendarMatches(startMs, endMs, deps) {
|
|
199
|
+
if (!process.env.CALENDAR_REFRESH_TOKEN) return {};
|
|
200
|
+
try {
|
|
201
|
+
const find = deps.listEvents ?? listEvents;
|
|
202
|
+
const data = await find({ from: iso(startMs - 30 * MIN), to: iso(endMs + 30 * MIN), limit: 25 });
|
|
203
|
+
return { calendar_matches: bestEventMatches(data.events || [], startMs, endMs) };
|
|
204
|
+
} catch (err) {
|
|
205
|
+
return { calendar_note: `Could not check the calendar: ${err.message}` };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------- meeting_transcribe
|
|
210
|
+
|
|
211
|
+
/** Transcribe a recording and keep the transcript as a draft meeting, so the agent only has to send the summary back. */
|
|
212
|
+
export async function transcribeMeeting({ path: file, title, language, vocabulary, started_at, deal_id, confirm } = {}, deps = {}) {
|
|
213
|
+
if (!file) throw new Error("path is required: the recording to transcribe.");
|
|
214
|
+
const name = title !== undefined ? cleanText(title, "title", 200) : undefined;
|
|
215
|
+
const given = started_at !== undefined ? parseDate(started_at, "started_at") : undefined;
|
|
216
|
+
if (deal_id !== undefined) {
|
|
217
|
+
checkInt(deal_id, "deal_id");
|
|
218
|
+
await getDeal({ deal_id });
|
|
219
|
+
}
|
|
220
|
+
const tr = await (deps.transcribeAudio ?? transcribeAudio)({ path: file, language, vocabulary }, deps.transcribeDeps);
|
|
221
|
+
|
|
222
|
+
const durationMs = tr.duration_seconds * 1000;
|
|
223
|
+
// A recording's file time is when it stopped, so the meeting began one duration earlier.
|
|
224
|
+
const startMs = given ?? tr.recorded_at_ms - durationMs;
|
|
225
|
+
const db = await getDb();
|
|
226
|
+
const ts = iso();
|
|
227
|
+
const r = db
|
|
228
|
+
.prepare(
|
|
229
|
+
`INSERT INTO meetings (title, status, started_at, duration_minutes, attendees, source, source_file, language, transcript, deal_id, created_at, updated_at)
|
|
230
|
+
VALUES (?, 'draft', ?, ?, '[]', 'audio', ?, ?, ?, ?, ?, ?)`
|
|
231
|
+
)
|
|
232
|
+
.run(name ?? titleFromFile(file), iso(startMs), Math.max(1, Math.round(tr.duration_seconds / 60)), tr.file, tr.language, tr.transcript, deal_id ?? null, ts, ts);
|
|
233
|
+
const id = Number(r.lastInsertRowid);
|
|
234
|
+
|
|
235
|
+
const cal = await calendarMatches(startMs, startMs + durationMs, deps);
|
|
236
|
+
return {
|
|
237
|
+
meeting_id: id,
|
|
238
|
+
status: "draft",
|
|
239
|
+
title: name ?? titleFromFile(file),
|
|
240
|
+
started_at: iso(startMs),
|
|
241
|
+
duration_minutes: Math.max(1, Math.round(tr.duration_seconds / 60)),
|
|
242
|
+
language: tr.language,
|
|
243
|
+
words: tr.text.split(/\s+/).length,
|
|
244
|
+
...(tr.converted ? { converted_with_ffmpeg: true } : {}),
|
|
245
|
+
transcript: tr.transcript,
|
|
246
|
+
...cal,
|
|
247
|
+
next: "Read the transcript, then call meeting_save with this meeting_id: a short summary, the decisions, and the action items (owner and due date when stated). Whisper does not identify speakers, so use context to work out who said what. If one calendar_matches entry is clearly this meeting, pass its event_id as calendar_event_id.",
|
|
248
|
+
notice: UNTRUSTED_NOTICE,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------- meeting_save
|
|
253
|
+
|
|
254
|
+
export async function saveMeeting(input = {}, deps = {}) {
|
|
255
|
+
const { meeting_id, log_to_deal = true } = input;
|
|
256
|
+
const tz = defaultTimezone();
|
|
257
|
+
const db = await getDb();
|
|
258
|
+
|
|
259
|
+
let existing = null;
|
|
260
|
+
if (meeting_id !== undefined) {
|
|
261
|
+
checkInt(meeting_id, "meeting_id");
|
|
262
|
+
existing = db.prepare("SELECT * FROM meetings WHERE id = ?").get(meeting_id);
|
|
263
|
+
if (!existing) throw new Error(`No meeting with id ${meeting_id}.`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const title = input.title !== undefined ? cleanText(input.title, "title", 200) : undefined;
|
|
267
|
+
const summary = input.summary !== undefined ? cleanText(input.summary, "summary", 20_000) : undefined;
|
|
268
|
+
const decisions = input.decisions !== undefined ? cleanList(input.decisions, "decisions", 50, 500) : undefined;
|
|
269
|
+
const actions = input.action_items !== undefined ? cleanActions(input.action_items) : undefined;
|
|
270
|
+
let attendees = input.attendees !== undefined ? cleanAttendees(input.attendees) : undefined;
|
|
271
|
+
const eventId = input.calendar_event_id !== undefined ? cleanText(input.calendar_event_id, "calendar_event_id", 200) : undefined;
|
|
272
|
+
let startedMs = input.started_at !== undefined ? parseDate(input.started_at, "started_at") : undefined;
|
|
273
|
+
let duration = input.duration_minutes !== undefined ? clamp(checkInt(input.duration_minutes, "duration_minutes"), 1, 1440) : undefined;
|
|
274
|
+
let dealId = existing?.deal_id ?? null;
|
|
275
|
+
if (input.deal_id !== undefined) {
|
|
276
|
+
checkInt(input.deal_id, "deal_id");
|
|
277
|
+
await getDeal({ deal_id: input.deal_id }); // throws if the deal does not exist
|
|
278
|
+
dealId = input.deal_id;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (input.transcript !== undefined && input.transcript_file !== undefined) throw new Error("Pass transcript or transcript_file, not both.");
|
|
282
|
+
let transcript;
|
|
283
|
+
let source;
|
|
284
|
+
let sourceFile;
|
|
285
|
+
if (input.transcript_file !== undefined) {
|
|
286
|
+
transcript = parseTranscriptFile(input.transcript_file);
|
|
287
|
+
source = "file";
|
|
288
|
+
sourceFile = path.basename(input.transcript_file);
|
|
289
|
+
} else if (input.transcript !== undefined) {
|
|
290
|
+
transcript = cleanText(input.transcript, "transcript", 2_000_000);
|
|
291
|
+
source = "text";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const notes = [];
|
|
295
|
+
let event;
|
|
296
|
+
if (eventId) {
|
|
297
|
+
try {
|
|
298
|
+
const got = await (deps.getEvent ?? getEvent)({ event_id: eventId });
|
|
299
|
+
event = got.event;
|
|
300
|
+
} catch (err) {
|
|
301
|
+
notes.push(`Linked the calendar event id, but could not read the event: ${err.message}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (event) {
|
|
305
|
+
const emails = (event.attendees || []).filter((a) => !a.self).map((a) => String(a.email).toLowerCase());
|
|
306
|
+
if (attendees === undefined && !parseJson(existing?.attendees, []).length) attendees = emails;
|
|
307
|
+
if (startedMs === undefined && !event.all_day && event.start) startedMs = Date.parse(event.start);
|
|
308
|
+
if (duration === undefined && !event.all_day && event.start && event.end) duration = Math.max(1, Math.round((Date.parse(event.end) - Date.parse(event.start)) / MIN));
|
|
309
|
+
}
|
|
310
|
+
// A draft from a recording is titled after its file; once a calendar event is linked, its name is better.
|
|
311
|
+
const autoTitled = existing?.source === "audio" && existing.source_file && existing.title === titleFromFile(existing.source_file);
|
|
312
|
+
const newTitle = title ?? (!existing || autoTitled ? event?.title : undefined);
|
|
313
|
+
|
|
314
|
+
if (!existing) {
|
|
315
|
+
if (!newTitle) throw new Error("title is required for a new meeting (or pass calendar_event_id to take it from the calendar).");
|
|
316
|
+
if (summary === undefined && transcript === undefined) throw new Error("Give a summary, or a transcript / transcript_file.");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const ts = iso();
|
|
320
|
+
const finalSummary = summary ?? existing?.summary ?? null;
|
|
321
|
+
const status = finalSummary ? "saved" : "draft";
|
|
322
|
+
const staleReminders = [];
|
|
323
|
+
const added = [];
|
|
324
|
+
let id;
|
|
325
|
+
|
|
326
|
+
db.exec("BEGIN");
|
|
327
|
+
try {
|
|
328
|
+
if (!existing) {
|
|
329
|
+
const r = db
|
|
330
|
+
.prepare(
|
|
331
|
+
`INSERT INTO meetings (title, status, started_at, duration_minutes, attendees, source, source_file, language, transcript, summary, decisions, calendar_event_id, deal_id, created_at, updated_at)
|
|
332
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
333
|
+
)
|
|
334
|
+
.run(
|
|
335
|
+
newTitle,
|
|
336
|
+
status,
|
|
337
|
+
iso(startedMs ?? Date.now()),
|
|
338
|
+
duration ?? null,
|
|
339
|
+
JSON.stringify(attendees ?? []),
|
|
340
|
+
source ?? "manual",
|
|
341
|
+
sourceFile ?? null,
|
|
342
|
+
input.language ? String(input.language).slice(0, 40) : null,
|
|
343
|
+
transcript ?? null,
|
|
344
|
+
finalSummary,
|
|
345
|
+
JSON.stringify(decisions ?? []),
|
|
346
|
+
eventId ?? null,
|
|
347
|
+
dealId,
|
|
348
|
+
ts,
|
|
349
|
+
ts
|
|
350
|
+
);
|
|
351
|
+
id = Number(r.lastInsertRowid);
|
|
352
|
+
} else {
|
|
353
|
+
id = existing.id;
|
|
354
|
+
db.prepare(
|
|
355
|
+
`UPDATE meetings SET title = ?, status = ?, started_at = ?, duration_minutes = ?, attendees = ?, source = ?, source_file = ?,
|
|
356
|
+
transcript = ?, summary = ?, decisions = ?, calendar_event_id = ?, deal_id = ?, updated_at = ? WHERE id = ?`
|
|
357
|
+
).run(
|
|
358
|
+
newTitle ?? existing.title,
|
|
359
|
+
status,
|
|
360
|
+
startedMs !== undefined ? iso(startedMs) : existing.started_at,
|
|
361
|
+
duration ?? existing.duration_minutes,
|
|
362
|
+
attendees !== undefined ? JSON.stringify(attendees) : existing.attendees,
|
|
363
|
+
source ?? existing.source,
|
|
364
|
+
sourceFile ?? existing.source_file,
|
|
365
|
+
transcript ?? existing.transcript,
|
|
366
|
+
finalSummary,
|
|
367
|
+
decisions !== undefined ? JSON.stringify(decisions) : existing.decisions,
|
|
368
|
+
eventId ?? existing.calendar_event_id,
|
|
369
|
+
dealId,
|
|
370
|
+
ts,
|
|
371
|
+
id
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (actions !== undefined) {
|
|
375
|
+
// Re-saving replaces the open items; finished ones are kept as history.
|
|
376
|
+
for (const old of db.prepare("SELECT id, reminder_id FROM action_items WHERE meeting_id = ? AND status = 'open'").all(id)) {
|
|
377
|
+
if (old.reminder_id) staleReminders.push(old.reminder_id);
|
|
378
|
+
}
|
|
379
|
+
db.prepare("DELETE FROM action_items WHERE meeting_id = ? AND status = 'open'").run(id);
|
|
380
|
+
const insert = db.prepare("INSERT INTO action_items (meeting_id, text, owner, due_date, status, created_at) VALUES (?, ?, ?, ?, 'open', ?)");
|
|
381
|
+
for (const a of actions) added.push({ ...a, id: Number(insert.run(id, a.text, a.owner, a.due_date, ts).lastInsertRowid) });
|
|
382
|
+
}
|
|
383
|
+
db.exec("COMMIT");
|
|
384
|
+
} catch (err) {
|
|
385
|
+
db.exec("ROLLBACK");
|
|
386
|
+
throw err;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const reminderId of staleReminders) await reminderUpdate({ id: reminderId, status: "cancelled" }).catch(() => {});
|
|
390
|
+
|
|
391
|
+
const meetingTitle = newTitle ?? existing.title;
|
|
392
|
+
let remindersCreated = 0;
|
|
393
|
+
for (const a of added.filter((x) => x.due_date)) {
|
|
394
|
+
try {
|
|
395
|
+
const rem = await reminderCreate({
|
|
396
|
+
title: `Action: ${a.text}`.slice(0, 200),
|
|
397
|
+
due_at: dueAtFor(a.due_date, tz),
|
|
398
|
+
note: [`From meeting: ${meetingTitle}`, a.owner ? `Owner: ${a.owner}` : null].filter(Boolean).join(". "),
|
|
399
|
+
deal_id: dealId ?? undefined,
|
|
400
|
+
});
|
|
401
|
+
db.prepare("UPDATE action_items SET reminder_id = ? WHERE id = ?").run(rem.id, a.id);
|
|
402
|
+
remindersCreated++;
|
|
403
|
+
} catch (err) {
|
|
404
|
+
notes.push(`Could not set a reminder for "${a.text.slice(0, 40)}": ${err.message}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let dealLogged = false;
|
|
409
|
+
const row = db.prepare("SELECT * FROM meetings WHERE id = ?").get(id);
|
|
410
|
+
if (dealId && log_to_deal !== false && row.summary && !row.deal_logged_at) {
|
|
411
|
+
const open = db.prepare("SELECT COUNT(*) AS n FROM action_items WHERE meeting_id = ? AND status = 'open'").get(id).n;
|
|
412
|
+
try {
|
|
413
|
+
await logActivity({
|
|
414
|
+
deal_id: dealId,
|
|
415
|
+
type: "meeting",
|
|
416
|
+
summary: `Meeting "${row.title}" (${row.started_at.slice(0, 10)}): ${row.summary.slice(0, 500)}${open ? ` [${open} open action item${open === 1 ? "" : "s"}]` : ""}`,
|
|
417
|
+
});
|
|
418
|
+
db.prepare("UPDATE meetings SET deal_logged_at = ? WHERE id = ?").run(iso(), id);
|
|
419
|
+
dealLogged = true;
|
|
420
|
+
} catch (err) {
|
|
421
|
+
notes.push(`Saved, but could not log it on the deal: ${err.message}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
saved: true,
|
|
427
|
+
meeting: view(db.prepare("SELECT * FROM meetings WHERE id = ?").get(id)),
|
|
428
|
+
action_items: itemsFor(db, id, tz),
|
|
429
|
+
reminders_created: remindersCreated,
|
|
430
|
+
...(dealId ? { deal_id: dealId, deal_logged: dealLogged || Boolean(row.deal_logged_at) } : {}),
|
|
431
|
+
...(notes.length ? { notes } : {}),
|
|
432
|
+
...(status === "draft" ? { warning: "No summary yet, so this is still a draft." } : {}),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------- list / get / search
|
|
437
|
+
|
|
438
|
+
export async function listMeetings({ deal_id, since, status = "all", limit = 20 } = {}) {
|
|
439
|
+
if (!["all", "saved", "draft"].includes(status)) throw new Error("status must be all, saved or draft.");
|
|
440
|
+
const clauses = [];
|
|
441
|
+
const params = [];
|
|
442
|
+
if (status !== "all") {
|
|
443
|
+
clauses.push("m.status = ?");
|
|
444
|
+
params.push(status);
|
|
445
|
+
}
|
|
446
|
+
if (deal_id !== undefined) {
|
|
447
|
+
clauses.push("m.deal_id = ?");
|
|
448
|
+
params.push(checkInt(deal_id, "deal_id"));
|
|
449
|
+
}
|
|
450
|
+
if (since !== undefined) {
|
|
451
|
+
clauses.push("m.started_at >= ?");
|
|
452
|
+
params.push(iso(parseDate(since, "since")));
|
|
453
|
+
}
|
|
454
|
+
const db = await getDb();
|
|
455
|
+
const rows = db
|
|
456
|
+
.prepare(
|
|
457
|
+
`SELECT m.*, (SELECT COUNT(*) FROM action_items a WHERE a.meeting_id = m.id AND a.status = 'open') AS open_actions
|
|
458
|
+
FROM meetings m ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY m.started_at DESC LIMIT ?`
|
|
459
|
+
)
|
|
460
|
+
.all(...params, clamp(Math.trunc(limit) || 20, 1, 100));
|
|
461
|
+
return {
|
|
462
|
+
count: rows.length,
|
|
463
|
+
meetings: rows.map((r) => ({ ...view(r), summary: r.summary ? (r.summary.length > 200 ? `${r.summary.slice(0, 200)}...` : r.summary) : undefined, open_actions: r.open_actions })),
|
|
464
|
+
notice: UNTRUSTED_NOTICE,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export async function getMeeting({ meeting_id, include_transcript = false, transcript_from = 0, transcript_chars = 30_000 } = {}) {
|
|
469
|
+
checkInt(meeting_id, "meeting_id");
|
|
470
|
+
const db = await getDb();
|
|
471
|
+
const row = db.prepare("SELECT * FROM meetings WHERE id = ?").get(meeting_id);
|
|
472
|
+
if (!row) throw new Error(`No meeting with id ${meeting_id}.`);
|
|
473
|
+
const out = { meeting: view(row), action_items: itemsFor(db, row.id, defaultTimezone()) };
|
|
474
|
+
if (include_transcript && row.transcript) {
|
|
475
|
+
const from = clamp(Math.trunc(transcript_from) || 0, 0, row.transcript.length);
|
|
476
|
+
const size = clamp(Math.trunc(transcript_chars) || 30_000, 1000, 100_000);
|
|
477
|
+
const end = Math.min(from + size, row.transcript.length);
|
|
478
|
+
out.transcript = row.transcript.slice(from, end);
|
|
479
|
+
if (end < row.transcript.length) out.transcript_next_from = end;
|
|
480
|
+
}
|
|
481
|
+
out.notice = UNTRUSTED_NOTICE;
|
|
482
|
+
return out;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const likePattern = (term) => `%${term.replace(/[\\%_]/g, "\\$&")}%`;
|
|
486
|
+
|
|
487
|
+
function snippet(text, terms, width = 240) {
|
|
488
|
+
const lines = String(text).split("\n");
|
|
489
|
+
const line = lines.find((l) => terms.some((t) => l.toLowerCase().includes(t))) ?? "";
|
|
490
|
+
if (line.length <= width) return line;
|
|
491
|
+
const at = Math.max(0, terms.map((t) => line.toLowerCase().indexOf(t)).filter((i) => i >= 0).sort((a, b) => a - b)[0] - 80);
|
|
492
|
+
return `${at ? "..." : ""}${line.slice(at, at + width)}...`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export async function searchMeetings({ query, limit = 10 } = {}) {
|
|
496
|
+
const terms = [
|
|
497
|
+
...new Set(
|
|
498
|
+
String(query ?? "")
|
|
499
|
+
.toLowerCase()
|
|
500
|
+
.split(/\s+/)
|
|
501
|
+
.map((t) => t.replace(/^["']+|["']+$/g, ""))
|
|
502
|
+
.filter((t) => t.length >= 2)
|
|
503
|
+
),
|
|
504
|
+
].slice(0, 8);
|
|
505
|
+
if (!terms.length) throw new Error("query is required: at least one word of 2 or more letters.");
|
|
506
|
+
const clause = `(lower(coalesce(m.title,'') || ' ' || coalesce(m.summary,'') || ' ' || coalesce(m.decisions,'') || ' ' || coalesce(m.attendees,'') || ' ' || coalesce(m.transcript,'')) LIKE ? ESCAPE '\\'
|
|
507
|
+
OR EXISTS (SELECT 1 FROM action_items a WHERE a.meeting_id = m.id AND lower(a.text || ' ' || coalesce(a.owner,'')) LIKE ? ESCAPE '\\'))`;
|
|
508
|
+
const params = terms.flatMap((t) => [likePattern(t), likePattern(t)]);
|
|
509
|
+
const db = await getDb();
|
|
510
|
+
const rows = db
|
|
511
|
+
.prepare(
|
|
512
|
+
`SELECT m.*, (SELECT group_concat(text, ' | ') FROM action_items WHERE meeting_id = m.id) AS action_text
|
|
513
|
+
FROM meetings m WHERE ${terms.map(() => clause).join(" AND ")} ORDER BY m.started_at DESC LIMIT ?`
|
|
514
|
+
)
|
|
515
|
+
.all(...params, clamp(Math.trunc(limit) || 10, 1, 50));
|
|
516
|
+
|
|
517
|
+
const has = (text) => text && terms.some((t) => String(text).toLowerCase().includes(t));
|
|
518
|
+
const results = rows.map((r) => {
|
|
519
|
+
const where = [
|
|
520
|
+
has(r.title) && "title",
|
|
521
|
+
has(r.summary) && "summary",
|
|
522
|
+
has(r.decisions) && "decisions",
|
|
523
|
+
has(r.action_text) && "action items",
|
|
524
|
+
has(r.attendees) && "attendees",
|
|
525
|
+
has(r.transcript) && "transcript",
|
|
526
|
+
].filter(Boolean);
|
|
527
|
+
const source = has(r.summary) ? r.summary : has(r.decisions) ? parseJson(r.decisions, []).join("\n") : has(r.action_text) ? r.action_text : r.transcript ?? "";
|
|
528
|
+
return { id: r.id, title: r.title, date: r.started_at.slice(0, 10), deal_id: r.deal_id ?? undefined, matched_in: where, snippet: snippet(source, terms) };
|
|
529
|
+
});
|
|
530
|
+
return { query, count: results.length, results, notice: UNTRUSTED_NOTICE };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ---------------------------------------------------------------- action items
|
|
534
|
+
|
|
535
|
+
export async function listActions({ status = "open", owner, meeting_id, due_before, limit = 50 } = {}) {
|
|
536
|
+
if (!["open", "done", "all"].includes(status)) throw new Error("status must be open, done or all.");
|
|
537
|
+
const clauses = [];
|
|
538
|
+
const params = [];
|
|
539
|
+
if (status !== "all") {
|
|
540
|
+
clauses.push("a.status = ?");
|
|
541
|
+
params.push(status);
|
|
542
|
+
}
|
|
543
|
+
if (owner) {
|
|
544
|
+
clauses.push("lower(a.owner) LIKE ? ESCAPE '\\'");
|
|
545
|
+
params.push(likePattern(String(owner).toLowerCase().trim()));
|
|
546
|
+
}
|
|
547
|
+
if (meeting_id !== undefined) {
|
|
548
|
+
clauses.push("a.meeting_id = ?");
|
|
549
|
+
params.push(checkInt(meeting_id, "meeting_id"));
|
|
550
|
+
}
|
|
551
|
+
if (due_before) {
|
|
552
|
+
clauses.push("a.due_date IS NOT NULL AND a.due_date <= ?");
|
|
553
|
+
params.push(checkDate(due_before, "due_before"));
|
|
554
|
+
}
|
|
555
|
+
const tz = defaultTimezone();
|
|
556
|
+
const today = todayIn(tz);
|
|
557
|
+
const db = await getDb();
|
|
558
|
+
const rows = db
|
|
559
|
+
.prepare(
|
|
560
|
+
`SELECT a.*, m.title AS meeting_title, m.started_at AS meeting_date, m.deal_id AS deal_id
|
|
561
|
+
FROM action_items a JOIN meetings m ON m.id = a.meeting_id
|
|
562
|
+
${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}
|
|
563
|
+
ORDER BY (a.due_date IS NULL), a.due_date ASC, a.id ASC LIMIT ?`
|
|
564
|
+
)
|
|
565
|
+
.all(...params, clamp(Math.trunc(limit) || 50, 1, 200));
|
|
566
|
+
const actions = rows.map((a) =>
|
|
567
|
+
compact({
|
|
568
|
+
id: a.id,
|
|
569
|
+
text: a.text,
|
|
570
|
+
owner: a.owner,
|
|
571
|
+
due_date: a.due_date,
|
|
572
|
+
status: a.status,
|
|
573
|
+
overdue: a.status === "open" && a.due_date && a.due_date < today ? true : undefined,
|
|
574
|
+
meeting_id: a.meeting_id,
|
|
575
|
+
meeting: a.meeting_title,
|
|
576
|
+
meeting_date: a.meeting_date.slice(0, 10),
|
|
577
|
+
deal_id: a.deal_id,
|
|
578
|
+
})
|
|
579
|
+
);
|
|
580
|
+
return { today, count: actions.length, overdue: actions.filter((a) => a.overdue).length, actions, notice: UNTRUSTED_NOTICE };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export async function completeAction({ action_id, status = "done" } = {}) {
|
|
584
|
+
checkInt(action_id, "action_id");
|
|
585
|
+
if (!["done", "open"].includes(status)) throw new Error("status must be done or open.");
|
|
586
|
+
const db = await getDb();
|
|
587
|
+
const a = db.prepare("SELECT * FROM action_items WHERE id = ?").get(action_id);
|
|
588
|
+
if (!a) throw new Error(`No action item with id ${action_id}.`);
|
|
589
|
+
db.prepare("UPDATE action_items SET status = ?, completed_at = ? WHERE id = ?").run(status, status === "done" ? iso() : null, action_id);
|
|
590
|
+
if (status === "done" && a.reminder_id) await reminderUpdate({ id: a.reminder_id, status: "done" }).catch(() => {});
|
|
591
|
+
const left = db.prepare("SELECT COUNT(*) AS n FROM action_items WHERE meeting_id = ? AND status = 'open'").get(a.meeting_id).n;
|
|
592
|
+
return { id: action_id, text: a.text, status, open_in_meeting: left };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ---------------------------------------------------------------- prep
|
|
596
|
+
|
|
597
|
+
export async function prepMeeting({ event_id, deal_id, attendees, query } = {}, deps = {}) {
|
|
598
|
+
if (event_id === undefined && deal_id === undefined && !attendees?.length && !query) {
|
|
599
|
+
throw new Error("Give at least one of: event_id (calendar), deal_id, attendees (emails) or query (company or topic).");
|
|
600
|
+
}
|
|
601
|
+
const notes = [];
|
|
602
|
+
let event;
|
|
603
|
+
if (event_id) {
|
|
604
|
+
try {
|
|
605
|
+
event = (await (deps.getEvent ?? getEvent)({ event_id: cleanText(event_id, "event_id", 200) })).event;
|
|
606
|
+
} catch (err) {
|
|
607
|
+
notes.push(`Could not read the calendar event: ${err.message}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const emails = [
|
|
611
|
+
...new Set([...(attendees ? cleanAttendees(attendees) : []), ...((event?.attendees || []).filter((a) => !a.self).map((a) => String(a.email).toLowerCase()))].filter((x) => x.includes("@"))),
|
|
612
|
+
].slice(0, 10);
|
|
613
|
+
|
|
614
|
+
const dealIds = new Set();
|
|
615
|
+
if (deal_id !== undefined) dealIds.add(checkInt(deal_id, "deal_id"));
|
|
616
|
+
for (const email of emails) {
|
|
617
|
+
const found = await listDeals({ query: email, status: "all", limit: 3 });
|
|
618
|
+
for (const d of found.deals) dealIds.add(d.id);
|
|
619
|
+
}
|
|
620
|
+
if (!dealIds.size && query) {
|
|
621
|
+
const found = await listDeals({ query: String(query), status: "all", limit: 3 });
|
|
622
|
+
for (const d of found.deals) dealIds.add(d.id);
|
|
623
|
+
}
|
|
624
|
+
const deals = [];
|
|
625
|
+
for (const id of [...dealIds].slice(0, 3)) {
|
|
626
|
+
const g = await getDeal({ deal_id: id });
|
|
627
|
+
const d = g.deal;
|
|
628
|
+
deals.push(
|
|
629
|
+
compact({
|
|
630
|
+
id: d.id,
|
|
631
|
+
company: d.company,
|
|
632
|
+
title: d.title,
|
|
633
|
+
stage: d.stage,
|
|
634
|
+
value: d.value ?? undefined,
|
|
635
|
+
currency: d.value != null ? d.currency : undefined,
|
|
636
|
+
next_step: d.next_step,
|
|
637
|
+
days_since_activity: d.days_since_activity,
|
|
638
|
+
primary_contact: d.contact_name ? `${d.contact_name}${d.contact_email ? ` <${d.contact_email}>` : ""}` : d.contact_email,
|
|
639
|
+
recent_activity: g.activities.slice(0, 5).map((a) => ({ type: a.type, summary: a.summary, at: a.occurred_at })),
|
|
640
|
+
pending_follow_ups: g.follow_ups.map((f) => ({ title: f.title, due_at: f.due_at })),
|
|
641
|
+
})
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const db = await getDb();
|
|
646
|
+
const conds = [];
|
|
647
|
+
const params = [];
|
|
648
|
+
if (dealIds.size) {
|
|
649
|
+
conds.push(`deal_id IN (${[...dealIds].map(() => "?").join(",")})`);
|
|
650
|
+
params.push(...dealIds);
|
|
651
|
+
}
|
|
652
|
+
for (const email of emails) {
|
|
653
|
+
conds.push("attendees LIKE ? ESCAPE '\\'");
|
|
654
|
+
params.push(likePattern(`"${email}"`));
|
|
655
|
+
}
|
|
656
|
+
for (const text of [query, event?.title]) {
|
|
657
|
+
if (text && String(text).trim().length >= 3) {
|
|
658
|
+
conds.push("lower(title) LIKE ? ESCAPE '\\'");
|
|
659
|
+
params.push(likePattern(String(text).toLowerCase().trim()));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const past = conds.length
|
|
663
|
+
? db.prepare(`SELECT * FROM meetings WHERE status = 'saved' AND (${conds.join(" OR ")}) ORDER BY started_at DESC LIMIT 5`).all(...params)
|
|
664
|
+
: [];
|
|
665
|
+
const tz = defaultTimezone();
|
|
666
|
+
const openActions = [];
|
|
667
|
+
const pastMeetings = past.map((m) => {
|
|
668
|
+
const items = itemsFor(db, m.id, tz);
|
|
669
|
+
for (const a of items.filter((i) => i.status === "open")) openActions.push({ ...a, meeting: m.title, meeting_id: m.id });
|
|
670
|
+
return { id: m.id, title: m.title, date: m.started_at.slice(0, 10), summary: m.summary, decisions: parseJson(m.decisions, []) };
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
...(event ? { event: compact({ title: event.title, start: event.start, end: event.end, location: event.location, meet_link: event.meet_link, description: event.description }) } : {}),
|
|
675
|
+
people: emails,
|
|
676
|
+
deals,
|
|
677
|
+
past_meetings: pastMeetings,
|
|
678
|
+
open_actions: openActions,
|
|
679
|
+
...(notes.length ? { notes } : {}),
|
|
680
|
+
...(!deals.length && !pastMeetings.length ? { note: "Nothing on file for these people yet: no pipeline deal and no past meeting." } : {}),
|
|
681
|
+
notice: UNTRUSTED_NOTICE,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ---------------------------------------------------------------- delete
|
|
686
|
+
|
|
687
|
+
export async function deleteMeeting({ meeting_id, confirm } = {}) {
|
|
688
|
+
checkInt(meeting_id, "meeting_id");
|
|
689
|
+
const db = await getDb();
|
|
690
|
+
const row = db.prepare("SELECT id, title FROM meetings WHERE id = ?").get(meeting_id);
|
|
691
|
+
if (!row) throw new Error(`No meeting with id ${meeting_id}.`);
|
|
692
|
+
const items = db.prepare("SELECT reminder_id FROM action_items WHERE meeting_id = ? AND status = 'open' AND reminder_id IS NOT NULL").all(meeting_id);
|
|
693
|
+
const count = db.prepare("SELECT COUNT(*) AS n FROM action_items WHERE meeting_id = ?").get(meeting_id).n;
|
|
694
|
+
db.prepare("DELETE FROM meetings WHERE id = ?").run(meeting_id); // action items go with it
|
|
695
|
+
for (const i of items) await reminderUpdate({ id: i.reminder_id, status: "cancelled" }).catch(() => {});
|
|
696
|
+
return { deleted: true, title: row.title, action_items_deleted: count, reminders_cancelled: items.length, note: "Any note already logged on a pipeline deal stays there." };
|
|
697
|
+
}
|