@vellumai/assistant 0.12.0-dev.202609110918.dd3fa5e → 0.12.0-dev.202609111417.417f7af
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/package.json +1 -1
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +144 -1
- package/src/notifications/home-feed-side-effect.ts +50 -7
- package/src/persistence/migrations/157-invite-contact-id.ts +18 -6
- package/src/persistence/migrations/__tests__/157-invite-contact-id.test.ts +149 -0
package/package.json
CHANGED
|
@@ -26,6 +26,15 @@ const messageRewrites: Array<{ messageId: string; content: string }> = [];
|
|
|
26
26
|
/** messageId -> the conversation it belongs to, for the scoped lookup. */
|
|
27
27
|
const messageOwners = new Map<string, string>();
|
|
28
28
|
let conversationRow: { conversationType: string } | null = null;
|
|
29
|
+
/**
|
|
30
|
+
* Rows for specific ids, consulted before the catch-all `conversationRow`,
|
|
31
|
+
* so a test can hand the signal's source and the delivery's paired
|
|
32
|
+
* conversation different shapes.
|
|
33
|
+
*/
|
|
34
|
+
const conversationRowsById = new Map<
|
|
35
|
+
string,
|
|
36
|
+
{ conversationType: string; source?: string }
|
|
37
|
+
>();
|
|
29
38
|
let conversationLookupShouldThrow = false;
|
|
30
39
|
let messageAppendShouldThrow = false;
|
|
31
40
|
let messageRewriteShouldThrow = false;
|
|
@@ -71,7 +80,7 @@ mock.module("../../persistence/conversation-crud.js", () => ({
|
|
|
71
80
|
if (conversationLookupShouldThrow) {
|
|
72
81
|
throw new Error("simulated conversation lookup failure");
|
|
73
82
|
}
|
|
74
|
-
return conversationRow;
|
|
83
|
+
return conversationRowsById.get(id) ?? conversationRow;
|
|
75
84
|
},
|
|
76
85
|
addMessage: async (
|
|
77
86
|
conversationId: string,
|
|
@@ -164,6 +173,7 @@ beforeEach(() => {
|
|
|
164
173
|
messageRewrites.length = 0;
|
|
165
174
|
messageOwners.clear();
|
|
166
175
|
conversationRow = null;
|
|
176
|
+
conversationRowsById.clear();
|
|
167
177
|
conversationLookupShouldThrow = false;
|
|
168
178
|
messageAppendShouldThrow = false;
|
|
169
179
|
messageRewriteShouldThrow = false;
|
|
@@ -402,6 +412,139 @@ describe("writeHomeFeedItemForSignal", () => {
|
|
|
402
412
|
expect(appendCalls[0]!.conversationId).toBe("paired-delivery-conv-id");
|
|
403
413
|
});
|
|
404
414
|
|
|
415
|
+
describe("assistant-initiated thread deliveries", () => {
|
|
416
|
+
// Under the assistant-initiated-threads flag, conversation pairing
|
|
417
|
+
// promotes a background assistant.share into a standard conversation
|
|
418
|
+
// stamped `assistant_initiated`, which the sidebar's assistant section
|
|
419
|
+
// renders. That thread is the share's surface; a feed item for the
|
|
420
|
+
// same signal put it in the bell a second time.
|
|
421
|
+
const assistantThreadDelivery = () =>
|
|
422
|
+
makeVellumDelivery({ conversationId: "assistant-thread-1" });
|
|
423
|
+
|
|
424
|
+
function stampAssistantThread(): void {
|
|
425
|
+
conversationRowsById.set("assistant-thread-1", {
|
|
426
|
+
conversationType: "standard",
|
|
427
|
+
source: "assistant_initiated",
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function shareFromHeartbeat(): NotificationSignal {
|
|
432
|
+
return makeSignal({
|
|
433
|
+
sourceChannel: "assistant_tool",
|
|
434
|
+
sourceEventName: "assistant.share",
|
|
435
|
+
sourceContextId: "conv-source-1",
|
|
436
|
+
contextPayload: { title: "Doctor checklist built" },
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const shareDecision = () =>
|
|
441
|
+
makeDecision({
|
|
442
|
+
selectedChannels: ["vellum"],
|
|
443
|
+
renderedCopy: {
|
|
444
|
+
vellum: {
|
|
445
|
+
title: "Doctor checklist built",
|
|
446
|
+
body: "Your doctor-visit one-pager is ready.",
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("a background share promoted into an assistant thread does not mirror", async () => {
|
|
452
|
+
conversationRow = { conversationType: "background" };
|
|
453
|
+
stampAssistantThread();
|
|
454
|
+
|
|
455
|
+
const item = await writeHomeFeedItemForSignal(
|
|
456
|
+
shareFromHeartbeat(),
|
|
457
|
+
shareDecision(),
|
|
458
|
+
assistantThreadDelivery(),
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
expect(item).toBeNull();
|
|
462
|
+
expect(appendCalls).toHaveLength(0);
|
|
463
|
+
expect(messageAppends).toHaveLength(0);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("the async-background hint does not override the assistant-thread suppression", async () => {
|
|
467
|
+
conversationRow = null;
|
|
468
|
+
stampAssistantThread();
|
|
469
|
+
const signal = makeSignal({
|
|
470
|
+
sourceChannel: "assistant_tool",
|
|
471
|
+
sourceEventName: "assistant.share",
|
|
472
|
+
sourceContextId: "heartbeat",
|
|
473
|
+
contextPayload: { title: "Bilbao solo trip is planned" },
|
|
474
|
+
attentionHints: {
|
|
475
|
+
requiresAction: false,
|
|
476
|
+
urgency: "medium",
|
|
477
|
+
isAsyncBackground: true,
|
|
478
|
+
visibleInSourceNow: false,
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const item = await writeHomeFeedItemForSignal(
|
|
483
|
+
signal,
|
|
484
|
+
shareDecision(),
|
|
485
|
+
assistantThreadDelivery(),
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
expect(item).toBeNull();
|
|
489
|
+
expect(appendCalls).toHaveLength(0);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("a share paired with an ordinary notification thread still mirrors", async () => {
|
|
493
|
+
// Flag off, or any producer that did not promote: pairing writes a
|
|
494
|
+
// `notification`-sourced row, which stays in Chats and the bell.
|
|
495
|
+
conversationRow = { conversationType: "background" };
|
|
496
|
+
conversationRowsById.set("assistant-thread-1", {
|
|
497
|
+
conversationType: "standard",
|
|
498
|
+
source: "notification",
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const item = await writeHomeFeedItemForSignal(
|
|
502
|
+
shareFromHeartbeat(),
|
|
503
|
+
shareDecision(),
|
|
504
|
+
assistantThreadDelivery(),
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
expect(item).not.toBeNull();
|
|
508
|
+
expect(appendCalls).toHaveLength(1);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("a guardian request keeps its bell item even when paired with an assistant thread", async () => {
|
|
512
|
+
// The bell is the request's canonical home: nothing else renders the
|
|
513
|
+
// pending card, so the assistant-thread suppression must not reach it.
|
|
514
|
+
conversationRow = { conversationType: "standard" };
|
|
515
|
+
stampAssistantThread();
|
|
516
|
+
const signal = makeSignal({
|
|
517
|
+
sourceChannel: "assistant_tool",
|
|
518
|
+
sourceEventName: "guardian.question",
|
|
519
|
+
contextPayload: {
|
|
520
|
+
title: "Approve this?",
|
|
521
|
+
questionText: "May I send the email?",
|
|
522
|
+
requestId: "req-1",
|
|
523
|
+
},
|
|
524
|
+
attentionHints: {
|
|
525
|
+
requiresAction: true,
|
|
526
|
+
urgency: "medium",
|
|
527
|
+
isAsyncBackground: false,
|
|
528
|
+
visibleInSourceNow: false,
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const item = await writeHomeFeedItemForSignal(
|
|
533
|
+
signal,
|
|
534
|
+
makeDecision({
|
|
535
|
+
selectedChannels: ["vellum"],
|
|
536
|
+
renderedCopy: {
|
|
537
|
+
vellum: { title: "Approve this?", body: "May I send the email?" },
|
|
538
|
+
},
|
|
539
|
+
}),
|
|
540
|
+
assistantThreadDelivery(),
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
expect(item).not.toBeNull();
|
|
544
|
+
expect(appendCalls).toHaveLength(1);
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
405
548
|
test("source conversation id wins over the paired delivery fallback when both are available", async () => {
|
|
406
549
|
// When the producer's `sourceContextId` already points at a real
|
|
407
550
|
// conversation (the canonical "where the work happened"), prefer it
|
|
@@ -23,7 +23,10 @@ import {
|
|
|
23
23
|
getMessageById,
|
|
24
24
|
updateMessageContent,
|
|
25
25
|
} from "../persistence/conversation-crud.js";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
ASSISTANT_INITIATED_SOURCE,
|
|
28
|
+
isBackgroundConversationType,
|
|
29
|
+
} from "../persistence/conversation-types.js";
|
|
27
30
|
import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js";
|
|
28
31
|
import { getLogger } from "../util/logger.js";
|
|
29
32
|
import { normalizeTitle, stripMarkdown } from "../util/short-title.js";
|
|
@@ -495,6 +498,18 @@ function deriveDetailPanelKind(
|
|
|
495
498
|
* `isAsyncBackground` hint. `chat.assistant_reply` also mirrors: it is the
|
|
496
499
|
* durable in-app record for the push sent after a user leaves a chat, while
|
|
497
500
|
* retaining the normal interactive conversation as its navigation target.
|
|
501
|
+
*
|
|
502
|
+
* A delivery that materialized an assistant-initiated thread never mirrors.
|
|
503
|
+
* Under the `assistant-initiated-threads` flag, `conversation-pairing.ts`
|
|
504
|
+
* promotes a background `assistant.share` into a standard conversation
|
|
505
|
+
* stamped {@link ASSISTANT_INITIATED_SOURCE}, and that thread's row in the
|
|
506
|
+
* sidebar's assistant section is the share's one surface. Mirroring it here
|
|
507
|
+
* as well put the same share in the bell a second time, labeled by its
|
|
508
|
+
* source channel ("Heartbeat"). The check reads the paired conversation's
|
|
509
|
+
* `source` rather than re-deriving the promotion rule, so it holds however
|
|
510
|
+
* that rule narrows or widens. Guardian requests are checked first on
|
|
511
|
+
* purpose: the bell is their canonical home whatever conversation they pair
|
|
512
|
+
* with.
|
|
498
513
|
*/
|
|
499
514
|
function resolveHomeFeedMirror(
|
|
500
515
|
signal: NotificationSignal,
|
|
@@ -525,6 +540,15 @@ function resolveHomeFeedMirror(
|
|
|
525
540
|
: fallbackConversationId;
|
|
526
541
|
const sourceScheduleJobId = sourceRow?.scheduleJobId ?? undefined;
|
|
527
542
|
|
|
543
|
+
// Guardian requests always project into the feed: the "Needs
|
|
544
|
+
// attention" item is the request's canonical home, and the request
|
|
545
|
+
// blocks on the guardian whatever kind of conversation raised it.
|
|
546
|
+
if (isGuardianRequestSignalEvent(signal.sourceEventName)) {
|
|
547
|
+
return { mirror: true, sourceConversationId, sourceScheduleJobId };
|
|
548
|
+
}
|
|
549
|
+
if (isAssistantInitiatedThreadDelivery(fallbackConversationId)) {
|
|
550
|
+
return { mirror: false };
|
|
551
|
+
}
|
|
528
552
|
if (
|
|
529
553
|
signal.sourceChannel === "assistant_tool" ||
|
|
530
554
|
signal.sourceEventName === "chat.assistant_reply"
|
|
@@ -534,18 +558,37 @@ function resolveHomeFeedMirror(
|
|
|
534
558
|
if (signal.attentionHints.isAsyncBackground) {
|
|
535
559
|
return { mirror: true, sourceConversationId, sourceScheduleJobId };
|
|
536
560
|
}
|
|
537
|
-
// Guardian requests always project into the feed: the "Needs
|
|
538
|
-
// attention" item is the request's canonical home, and the request
|
|
539
|
-
// blocks on the guardian whatever kind of conversation raised it.
|
|
540
|
-
if (isGuardianRequestSignalEvent(signal.sourceEventName)) {
|
|
541
|
-
return { mirror: true, sourceConversationId, sourceScheduleJobId };
|
|
542
|
-
}
|
|
543
561
|
if (isBackgroundConversationType(sourceRow?.conversationType)) {
|
|
544
562
|
return { mirror: true, sourceConversationId, sourceScheduleJobId };
|
|
545
563
|
}
|
|
546
564
|
return { mirror: false };
|
|
547
565
|
}
|
|
548
566
|
|
|
567
|
+
/**
|
|
568
|
+
* Whether the vellum delivery this signal produced landed in a thread the
|
|
569
|
+
* sidebar's assistant section already shows.
|
|
570
|
+
*
|
|
571
|
+
* Best-effort like the source lookup above: a missing id, a missing row, or
|
|
572
|
+
* a lookup failure all read as "not an assistant thread", so a storage
|
|
573
|
+
* hiccup degrades to the pre-existing behavior (a bell row) rather than
|
|
574
|
+
* dropping the notification everywhere.
|
|
575
|
+
*/
|
|
576
|
+
function isAssistantInitiatedThreadDelivery(
|
|
577
|
+
deliveryConversationId: string | undefined,
|
|
578
|
+
): boolean {
|
|
579
|
+
if (!deliveryConversationId) {
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
return (
|
|
584
|
+
getConversation(deliveryConversationId)?.source ===
|
|
585
|
+
ASSISTANT_INITIATED_SOURCE
|
|
586
|
+
);
|
|
587
|
+
} catch {
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
549
592
|
/**
|
|
550
593
|
* Routing-intent enforcement can prune `selectedChannels` without also
|
|
551
594
|
* pruning `renderedCopy`, so iterating `renderedCopy` directly risks
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
|
|
2
|
+
import { tableExists } from "./schema-introspection.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Add a required contact_id column to assistant_ingress_invites.
|
|
@@ -10,8 +11,16 @@ import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
|
|
|
10
11
|
* 1. Add the column as nullable.
|
|
11
12
|
* 2. Delete legacy rows that have no contact binding (NULL).
|
|
12
13
|
* 3. Rebuild the table to enforce NOT NULL (SQLite cannot ALTER COLUMN).
|
|
14
|
+
*
|
|
15
|
+
* The rebuild copies whichever session-id column is live:
|
|
16
|
+
* `source_conversation_id` (after migrateRenameCreatedBySessionIdColumns)
|
|
17
|
+
* or `created_by_session_id`.
|
|
13
18
|
*/
|
|
14
19
|
export function migrateInviteContactId(database: DrizzleDb): void {
|
|
20
|
+
if (!tableExists(database, "assistant_ingress_invites")) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
const raw = getSqliteFrom(database);
|
|
16
25
|
|
|
17
26
|
const cols = (
|
|
@@ -20,25 +29,28 @@ export function migrateInviteContactId(database: DrizzleDb): void {
|
|
|
20
29
|
notnull: number;
|
|
21
30
|
}>
|
|
22
31
|
).map((c) => ({ name: c.name, notnull: c.notnull }));
|
|
32
|
+
const colNames = new Set(cols.map((c) => c.name));
|
|
23
33
|
|
|
24
34
|
const col = cols.find((c) => c.name === "contact_id");
|
|
25
35
|
|
|
26
|
-
// Step 1: Add the column as nullable
|
|
27
36
|
if (!col) {
|
|
28
37
|
raw.exec(
|
|
29
38
|
/*sql*/ `ALTER TABLE assistant_ingress_invites ADD COLUMN contact_id TEXT`,
|
|
30
39
|
);
|
|
31
40
|
}
|
|
32
41
|
|
|
33
|
-
// Step 2: Delete legacy rows with no contact binding
|
|
34
42
|
raw.exec(
|
|
35
43
|
/*sql*/ `DELETE FROM assistant_ingress_invites WHERE contact_id IS NULL OR contact_id = ''`,
|
|
36
44
|
);
|
|
37
45
|
|
|
38
|
-
// Step 3: Rebuild the table with NOT NULL on contact_id
|
|
39
46
|
if (col && col.notnull === 1) {
|
|
40
47
|
return;
|
|
41
|
-
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sessionCol = colNames.has("source_conversation_id")
|
|
51
|
+
? "source_conversation_id"
|
|
52
|
+
: "created_by_session_id";
|
|
53
|
+
const sessionSelect = colNames.has(sessionCol) ? sessionCol : "NULL";
|
|
42
54
|
|
|
43
55
|
raw.exec("PRAGMA foreign_keys = OFF");
|
|
44
56
|
try {
|
|
@@ -49,7 +61,7 @@ export function migrateInviteContactId(database: DrizzleDb): void {
|
|
|
49
61
|
id TEXT PRIMARY KEY,
|
|
50
62
|
source_channel TEXT NOT NULL,
|
|
51
63
|
token_hash TEXT NOT NULL,
|
|
52
|
-
|
|
64
|
+
${sessionCol} TEXT,
|
|
53
65
|
note TEXT,
|
|
54
66
|
max_uses INTEGER NOT NULL DEFAULT 1 CHECK (max_uses > 0),
|
|
55
67
|
use_count INTEGER NOT NULL DEFAULT 0 CHECK (use_count >= 0),
|
|
@@ -70,7 +82,7 @@ export function migrateInviteContactId(database: DrizzleDb): void {
|
|
|
70
82
|
);
|
|
71
83
|
|
|
72
84
|
INSERT INTO assistant_ingress_invites_new
|
|
73
|
-
SELECT id, source_channel, token_hash,
|
|
85
|
+
SELECT id, source_channel, token_hash, ${sessionSelect}, note,
|
|
74
86
|
max_uses, use_count, expires_at, status,
|
|
75
87
|
redeemed_by_external_user_id, redeemed_by_external_chat_id,
|
|
76
88
|
redeemed_at, expected_external_user_id, voice_code_hash,
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
|
|
4
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
5
|
+
|
|
6
|
+
import { getSqliteFrom } from "../../db-connection.js";
|
|
7
|
+
import * as schema from "../../schema.js";
|
|
8
|
+
import { migrateInviteContactId } from "../157-invite-contact-id.js";
|
|
9
|
+
import { tableExists, tableHasColumn } from "../schema-introspection.js";
|
|
10
|
+
|
|
11
|
+
function createTestDb() {
|
|
12
|
+
const sqlite = new Database(":memory:");
|
|
13
|
+
sqlite.exec("PRAGMA journal_mode=WAL");
|
|
14
|
+
return drizzle(sqlite, { schema });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function columnNotNull(raw: Database, name: string): boolean {
|
|
18
|
+
const cols = raw.query(`PRAGMA table_info(assistant_ingress_invites)`).all() as Array<{
|
|
19
|
+
name: string;
|
|
20
|
+
notnull: number;
|
|
21
|
+
}>;
|
|
22
|
+
return cols.some((c) => c.name === name && c.notnull === 1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("migrateInviteContactId", () => {
|
|
26
|
+
test("rebuilds a legacy created_by_session_id table with NOT NULL contact_id", () => {
|
|
27
|
+
const db = createTestDb();
|
|
28
|
+
const raw = getSqliteFrom(db);
|
|
29
|
+
raw.exec(/*sql*/ `
|
|
30
|
+
CREATE TABLE assistant_ingress_invites (
|
|
31
|
+
id TEXT PRIMARY KEY,
|
|
32
|
+
source_channel TEXT NOT NULL,
|
|
33
|
+
token_hash TEXT NOT NULL,
|
|
34
|
+
created_by_session_id TEXT,
|
|
35
|
+
note TEXT,
|
|
36
|
+
max_uses INTEGER NOT NULL DEFAULT 1,
|
|
37
|
+
use_count INTEGER NOT NULL DEFAULT 0,
|
|
38
|
+
expires_at INTEGER NOT NULL,
|
|
39
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
40
|
+
redeemed_by_external_user_id TEXT,
|
|
41
|
+
redeemed_by_external_chat_id TEXT,
|
|
42
|
+
redeemed_at INTEGER,
|
|
43
|
+
expected_external_user_id TEXT,
|
|
44
|
+
voice_code_hash TEXT,
|
|
45
|
+
voice_code_digits INTEGER,
|
|
46
|
+
invite_code_hash TEXT,
|
|
47
|
+
friend_name TEXT,
|
|
48
|
+
guardian_name TEXT,
|
|
49
|
+
created_at INTEGER NOT NULL,
|
|
50
|
+
updated_at INTEGER NOT NULL
|
|
51
|
+
)
|
|
52
|
+
`);
|
|
53
|
+
raw.run(
|
|
54
|
+
`INSERT INTO assistant_ingress_invites
|
|
55
|
+
(id, source_channel, token_hash, created_by_session_id, max_uses, use_count,
|
|
56
|
+
expires_at, status, created_at, updated_at)
|
|
57
|
+
VALUES ('inv-keep', 'slack', 'hash-keep', 'conv-1', 1, 0, 9, 'active', 1, 1)`,
|
|
58
|
+
);
|
|
59
|
+
raw.exec(
|
|
60
|
+
`ALTER TABLE assistant_ingress_invites ADD COLUMN contact_id TEXT`,
|
|
61
|
+
);
|
|
62
|
+
raw.run(
|
|
63
|
+
`UPDATE assistant_ingress_invites SET contact_id = 'contact-keep' WHERE id = 'inv-keep'`,
|
|
64
|
+
);
|
|
65
|
+
raw.run(
|
|
66
|
+
`INSERT INTO assistant_ingress_invites
|
|
67
|
+
(id, source_channel, token_hash, max_uses, use_count, expires_at, status,
|
|
68
|
+
created_at, updated_at)
|
|
69
|
+
VALUES ('inv-drop', 'slack', 'hash-drop', 1, 0, 9, 'active', 1, 1)`,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
migrateInviteContactId(db);
|
|
73
|
+
|
|
74
|
+
expect(tableHasColumn(db, "assistant_ingress_invites", "contact_id")).toBe(
|
|
75
|
+
true,
|
|
76
|
+
);
|
|
77
|
+
expect(columnNotNull(raw, "contact_id")).toBe(true);
|
|
78
|
+
expect(
|
|
79
|
+
tableHasColumn(db, "assistant_ingress_invites", "created_by_session_id"),
|
|
80
|
+
).toBe(true);
|
|
81
|
+
const rows = raw
|
|
82
|
+
.query(`SELECT id, contact_id FROM assistant_ingress_invites ORDER BY id`)
|
|
83
|
+
.all() as Array<{ id: string; contact_id: string }>;
|
|
84
|
+
expect(rows).toEqual([{ id: "inv-keep", contact_id: "contact-keep" }]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("rebuilds after created_by_session_id was renamed to source_conversation_id", () => {
|
|
88
|
+
const db = createTestDb();
|
|
89
|
+
const raw = getSqliteFrom(db);
|
|
90
|
+
raw.exec(/*sql*/ `
|
|
91
|
+
CREATE TABLE assistant_ingress_invites (
|
|
92
|
+
id TEXT PRIMARY KEY,
|
|
93
|
+
source_channel TEXT NOT NULL,
|
|
94
|
+
token_hash TEXT NOT NULL,
|
|
95
|
+
source_conversation_id TEXT,
|
|
96
|
+
note TEXT,
|
|
97
|
+
max_uses INTEGER NOT NULL DEFAULT 1,
|
|
98
|
+
use_count INTEGER NOT NULL DEFAULT 0,
|
|
99
|
+
expires_at INTEGER NOT NULL,
|
|
100
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
101
|
+
redeemed_by_external_user_id TEXT,
|
|
102
|
+
redeemed_by_external_chat_id TEXT,
|
|
103
|
+
redeemed_at INTEGER,
|
|
104
|
+
expected_external_user_id TEXT,
|
|
105
|
+
voice_code_hash TEXT,
|
|
106
|
+
voice_code_digits INTEGER,
|
|
107
|
+
invite_code_hash TEXT,
|
|
108
|
+
friend_name TEXT,
|
|
109
|
+
guardian_name TEXT,
|
|
110
|
+
created_at INTEGER NOT NULL,
|
|
111
|
+
updated_at INTEGER NOT NULL
|
|
112
|
+
)
|
|
113
|
+
`);
|
|
114
|
+
raw.exec(
|
|
115
|
+
`ALTER TABLE assistant_ingress_invites ADD COLUMN contact_id TEXT`,
|
|
116
|
+
);
|
|
117
|
+
raw.run(
|
|
118
|
+
`INSERT INTO assistant_ingress_invites
|
|
119
|
+
(id, source_channel, token_hash, source_conversation_id, contact_id,
|
|
120
|
+
max_uses, use_count, expires_at, status, created_at, updated_at)
|
|
121
|
+
VALUES ('inv-1', 'slack', 'hash-1', 'conv-9', 'contact-1', 1, 0, 9, 'active', 1, 1)`,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
migrateInviteContactId(db);
|
|
125
|
+
|
|
126
|
+
expect(columnNotNull(raw, "contact_id")).toBe(true);
|
|
127
|
+
expect(
|
|
128
|
+
tableHasColumn(db, "assistant_ingress_invites", "source_conversation_id"),
|
|
129
|
+
).toBe(true);
|
|
130
|
+
expect(
|
|
131
|
+
tableHasColumn(db, "assistant_ingress_invites", "created_by_session_id"),
|
|
132
|
+
).toBe(false);
|
|
133
|
+
const row = raw
|
|
134
|
+
.query(
|
|
135
|
+
`SELECT source_conversation_id, contact_id FROM assistant_ingress_invites WHERE id = 'inv-1'`,
|
|
136
|
+
)
|
|
137
|
+
.get() as { source_conversation_id: string; contact_id: string };
|
|
138
|
+
expect(row).toEqual({
|
|
139
|
+
source_conversation_id: "conv-9",
|
|
140
|
+
contact_id: "contact-1",
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("is a no-op when the table does not exist", () => {
|
|
145
|
+
const db = createTestDb();
|
|
146
|
+
migrateInviteContactId(db);
|
|
147
|
+
expect(tableExists(db, "assistant_ingress_invites")).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|