Retire removed accounts instead of deleting them
Removing an account had no path at all, so a dead session could only be left running or hand-edited out of the database. The first cut deleted the account and everything it owned, which threw away conversations and media that are worth keeping. Retire instead: keep the row and every row it produced, destroy the stored cookies and Matrix token. Wiping the credentials is what makes sending and syncing impossible rather than merely disabled — isEnabledAccount requires a verified status, so no runtime ever starts, and no session survives for anything to use. setActive, markLoginRequired and the background credential refresh now skip retired accounts so none of them quietly revives one; re-importing a session deliberately does. Keeping the content visible needed more than keeping the rows. Conversations and media are only served from running runtimes, so a retired account's history would have disappeared from the dashboard regardless. Media already reads the account content table for every listed account. Conversations now have an archive path that reads the stored ActiveChat payloads directly. Every write path refuses a retired conversation with a 409: send-reply, both suggest endpoints, share-media, shareable-media and all read-state writes. Lazy-loading detail is skipped too — the fallback chat service belongs to a different account, so it would sync the wrong session and write the wrong cache. Archived detail derives conversation state from the transcript alone for the same reason. Migration 32 rebuilds reddit_accounts to admit the new status, with foreign keys off and a foreign_key_check before they are restored, as migration 11 does. Two migration tests rewound user_version by counting back from the head, which silently breaks whenever a migration is appended; they now target absolute versions. Conversations never opened before removal keep only their summary. Message history is lazy-loaded on first open and there is no session left to fetch the rest with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3f60f7f665
commit
a9cea7ed18
16 files changed
+424
-88
No files matched your search
@@ -98,6 +98,7 @@ function establishmentPill(establishment: AccountEstablishmentSummary | null | u
|
|||||||
function AccountRow({ account }: { account: RedditAccountSummary }) {
|
function AccountRow({ account }: { account: RedditAccountSummary }) {
|
||||||
const removeMutation = useRemoveRedditAccountMutation();
|
const removeMutation = useRemoveRedditAccountMutation();
|
||||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
|
const isRemoved = account.status === 'removed';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!confirmRemove) return;
|
if (!confirmRemove) return;
|
||||||
@@ -105,24 +106,31 @@ function AccountRow({ account }: { account: RedditAccountSummary }) {
|
|||||||
return () => globalThis.clearTimeout(timer);
|
return () => globalThis.clearTimeout(timer);
|
||||||
}, [confirmRemove]);
|
}, [confirmRemove]);
|
||||||
|
|
||||||
|
const rowClass = isRemoved ? 'account-row is-removed' : account.isRunning ? 'account-row is-active' : 'account-row';
|
||||||
return (
|
return (
|
||||||
<div className={account.isRunning ? 'account-row is-active' : 'account-row'}>
|
<div className={rowClass}>
|
||||||
<div>
|
<div>
|
||||||
<strong><RedditUsernameLink username={account.redditUsername} /></strong>
|
<strong><RedditUsernameLink username={account.redditUsername} /></strong>
|
||||||
<span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span>
|
<span>
|
||||||
|
{isRemoved
|
||||||
|
? 'Removed — its conversations and media are kept, read-only'
|
||||||
|
: account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="account-row-pills">
|
<div className="account-row-pills">
|
||||||
{establishmentPill(account.establishment)}
|
{isRemoved ? null : establishmentPill(account.establishment)}
|
||||||
<span className={account.isRunning ? 'pill success' : 'pill warning'}>
|
<span className={isRemoved ? 'pill neutral' : account.isRunning ? 'pill success' : 'pill warning'}>
|
||||||
{account.isRunning ? 'Running' : 'Paused'}
|
{isRemoved ? 'Removed' : account.isRunning ? 'Running' : 'Paused'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="account-row-actions">
|
<div className="account-row-actions">
|
||||||
{confirmRemove ? (
|
{isRemoved ? (
|
||||||
|
<span className="field-hint">Import a session for this account to bring it back.</span>
|
||||||
|
) : confirmRemove ? (
|
||||||
<>
|
<>
|
||||||
<span className="account-remove-warning">
|
<span className="account-remove-warning">
|
||||||
Removes u/{account.redditUsername}, its stored session and all of its messages,
|
Stops u/{account.redditUsername} and deletes its stored session. Conversations,
|
||||||
conversations and activity. This cannot be undone.
|
messages and media it collected are kept, but become read-only.
|
||||||
</span>
|
</span>
|
||||||
<button className="button secondary" onClick={() => setConfirmRemove(false)} type="button">
|
<button className="button secondary" onClick={() => setConfirmRemove(false)} type="button">
|
||||||
Cancel
|
Cancel
|
||||||
@@ -151,7 +159,7 @@ function AccountRow({ account }: { account: RedditAccountSummary }) {
|
|||||||
{removeMutation.isError ? (
|
{removeMutation.isError ? (
|
||||||
<strong className="save-error" role="alert">{errorMessage(removeMutation.error)}</strong>
|
<strong className="save-error" role="alert">{errorMessage(removeMutation.error)}</strong>
|
||||||
) : null}
|
) : null}
|
||||||
{account.establishment && account.establishment.state !== 'unknown' ? (
|
{!isRemoved && account.establishment && account.establishment.state !== 'unknown' ? (
|
||||||
<EstablishmentDetail establishment={account.establishment} />
|
<EstablishmentDetail establishment={account.establishment} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -203,8 +211,10 @@ export function AccountsPage() {
|
|||||||
? session.username ?? null
|
? session.username ?? null
|
||||||
: null;
|
: null;
|
||||||
const accounts = accountsQuery.data ?? [];
|
const accounts = accountsQuery.data ?? [];
|
||||||
const pausedCount = accounts.filter((account) => !account.isRunning).length;
|
// Removed accounts are not paused work — nothing about them needs attention.
|
||||||
const heldAccounts = accounts.filter((account) => account.establishment?.sendingHeld);
|
const liveAccounts = accounts.filter((account) => account.status !== 'removed');
|
||||||
|
const pausedCount = liveAccounts.filter((account) => !account.isRunning).length;
|
||||||
|
const heldAccounts = liveAccounts.filter((account) => account.establishment?.sendingHeld);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page accounts-page">
|
<div className="page accounts-page">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
.account-list { display: grid; gap: .6rem; }
|
.account-list { display: grid; gap: .6rem; }
|
||||||
.account-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .85rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
|
.account-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .85rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
|
||||||
.account-row.is-active { border-color: var(--accent); }
|
.account-row.is-active { border-color: var(--accent); }
|
||||||
|
.account-row.is-removed { opacity: .72; }
|
||||||
.account-row > div { display: grid; gap: .2rem; min-width: 0; }
|
.account-row > div { display: grid; gap: .2rem; min-width: 0; }
|
||||||
.account-row span { color: var(--muted); font-size: .82rem; }
|
.account-row span { color: var(--muted); font-size: .82rem; }
|
||||||
.account-row .pill { font-size: .7rem; }
|
.account-row .pill { font-size: .7rem; }
|
||||||
|
|||||||
@@ -536,7 +536,8 @@ function useAutoMarkRead(conversationId: string, conversation: ConversationDetai
|
|||||||
const mutate = markRead.mutate;
|
const mutate = markRead.mutate;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversation || newestTimestamp <= lastReadMessageTs) return;
|
if (!conversation || conversation.readOnly) return;
|
||||||
|
if (newestTimestamp <= lastReadMessageTs) return;
|
||||||
const key = `${conversationId}:${newestTimestamp}`;
|
const key = `${conversationId}:${newestTimestamp}`;
|
||||||
if (markedRef.current === key) return;
|
if (markedRef.current === key) return;
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
@@ -585,11 +586,13 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="conversation-detail-controls">
|
<div className="conversation-detail-controls">
|
||||||
|
{conversation.readOnly ? null : (
|
||||||
<ConversationTriageActions
|
<ConversationTriageActions
|
||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
conversationId={conversationId}
|
conversationId={conversationId}
|
||||||
onResolved={onClose}
|
onResolved={onClose}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
|
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -617,7 +620,19 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<MessageHistory conversation={conversation} now={now} />
|
<MessageHistory conversation={conversation} now={now} />
|
||||||
|
{conversation.readOnly ? (
|
||||||
|
<section aria-labelledby="read-only-heading" className="conversation-read-only" role="status">
|
||||||
|
<h3 id="read-only-heading">Read-only</h3>
|
||||||
|
<p>
|
||||||
|
{conversation.owner?.redditUsername
|
||||||
|
? `u/${conversation.owner.redditUsername} was removed from the app.`
|
||||||
|
: 'The account that owned this conversation was removed from the app.'}
|
||||||
|
{' '}Its history is kept here, but nothing can be sent or synced from it.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
<ConversationComposer conversation={conversation} conversationId={conversationId} />
|
<ConversationComposer conversation={conversation} conversationId={conversationId} />
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -127,6 +127,7 @@ function ConversationRow({
|
|||||||
if (isSelected) itemClasses.push('is-selected');
|
if (isSelected) itemClasses.push('is-selected');
|
||||||
if (unread) itemClasses.push('is-unread');
|
if (unread) itemClasses.push('is-unread');
|
||||||
if (isChecked) itemClasses.push('is-checked');
|
if (isChecked) itemClasses.push('is-checked');
|
||||||
|
if (conversation.readOnly) itemClasses.push('is-read-only');
|
||||||
|
|
||||||
const descriptors = [
|
const descriptors = [
|
||||||
redditUsernameLabel(conversation.username),
|
redditUsernameLabel(conversation.username),
|
||||||
@@ -134,6 +135,7 @@ function ConversationRow({
|
|||||||
relativeTime,
|
relativeTime,
|
||||||
...(conversation.sellerDetected ? ['Seller'] : []),
|
...(conversation.sellerDetected ? ['Seller'] : []),
|
||||||
...(conversation.hasReceivedImages ? ['Image received'] : []),
|
...(conversation.hasReceivedImages ? ['Image received'] : []),
|
||||||
|
...(conversation.readOnly ? ['Removed account, read-only'] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -179,6 +181,11 @@ function ConversationRow({
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{showOwner ? <AccountOwnerBadge owner={conversation.owner} /> : null}
|
{showOwner ? <AccountOwnerBadge owner={conversation.owner} /> : null}
|
||||||
|
{conversation.readOnly ? (
|
||||||
|
<span className="conversation-read-only-marker" title="The account that owned this conversation was removed. History is kept, but nothing can be sent.">
|
||||||
|
Read-only
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{conversation.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
|
{conversation.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
|
||||||
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
|
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
|
||||||
<span className="conversation-list-profile"><RedditUsernameLink username={conversation.username} /></span>
|
<span className="conversation-list-profile"><RedditUsernameLink username={conversation.username} /></span>
|
||||||
|
|||||||
@@ -96,6 +96,11 @@
|
|||||||
.conversation-shortcut-hint { margin-left: auto; opacity: .8; }
|
.conversation-shortcut-hint { margin-left: auto; opacity: .8; }
|
||||||
.conversation-image-marker { justify-self: start; padding: .18rem .45rem; color: #d8e8ff !important; background: #18324d; border: 1px solid #315d85; border-radius: 999px; font-size: .68rem !important; font-weight: 750; }
|
.conversation-image-marker { justify-self: start; padding: .18rem .45rem; color: #d8e8ff !important; background: #18324d; border: 1px solid #315d85; border-radius: 999px; font-size: .68rem !important; font-weight: 750; }
|
||||||
.conversation-seller-marker { display: inline-flex; justify-self: start; padding: .18rem .45rem; margin-right: .35rem; color: #ffd6d0 !important; background: #4b1e1a; border: 1px solid #874139; border-radius: 999px; font-size: .68rem !important; font-weight: 800; }
|
.conversation-seller-marker { display: inline-flex; justify-self: start; padding: .18rem .45rem; margin-right: .35rem; color: #ffd6d0 !important; background: #4b1e1a; border: 1px solid #874139; border-radius: 999px; font-size: .68rem !important; font-weight: 800; }
|
||||||
|
.conversation-read-only-marker { display: inline-flex; justify-self: start; padding: .18rem .45rem; margin-right: .35rem; color: var(--muted) !important; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 999px; font-size: .68rem !important; font-weight: 800; }
|
||||||
|
.conversation-list-item.is-read-only .conversation-list-snippet { opacity: .8; }
|
||||||
|
.conversation-read-only { padding: 1rem; margin: 0 1rem 1rem; color: var(--muted); background: var(--surface-raised); border: 1px dashed var(--border); border-radius: .65rem; }
|
||||||
|
.conversation-read-only h3 { margin: 0 0 .3rem; color: var(--text); font-size: .9rem; }
|
||||||
|
.conversation-read-only p { margin: 0; font-size: .82rem; }
|
||||||
.conversation-list-state, .conversation-detail-state { display: grid; place-items: center; align-content: center; gap: .5rem; min-height: 16rem; padding: 1.5rem; color: var(--muted); text-align: center; }
|
.conversation-list-state, .conversation-detail-state { display: grid; place-items: center; align-content: center; gap: .5rem; min-height: 16rem; padding: 1.5rem; color: var(--muted); text-align: center; }
|
||||||
.conversation-list-state strong, .conversation-detail-state strong { color: #e9f1ec; }
|
.conversation-list-state strong, .conversation-detail-state strong { color: #e9f1ec; }
|
||||||
/* Column layout: history takes the slack, the composer stays reachable. */
|
/* Column layout: history takes the slack, the composer stays reachable. */
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ export const ActiveChatSchema = z.strictObject({
|
|||||||
automationPolicyMode: ConversationPolicyModeSchema.optional(),
|
automationPolicyMode: ConversationPolicyModeSchema.optional(),
|
||||||
/** Why automation handed over, when it has. */
|
/** Why automation handed over, when it has. */
|
||||||
automationHandoffReason: z.string().nullable().optional(),
|
automationHandoffReason: z.string().nullable().optional(),
|
||||||
|
/**
|
||||||
|
* The owning account was removed. Its history is kept and readable, but the
|
||||||
|
* app can no longer send into the conversation or sync it.
|
||||||
|
*/
|
||||||
|
readOnly: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
export const ChatsResponseSchema = z.array(ActiveChatSchema);
|
export const ChatsResponseSchema = z.array(ActiveChatSchema);
|
||||||
export const ConversationsResponseSchema = ChatsResponseSchema;
|
export const ConversationsResponseSchema = ChatsResponseSchema;
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ export const RedditAccountSummarySchema = z.strictObject({
|
|||||||
redditUsername: z.string().min(1),
|
redditUsername: z.string().min(1),
|
||||||
matrixUserId: z.string().min(1),
|
matrixUserId: z.string().min(1),
|
||||||
isRunning: z.boolean(),
|
isRunning: z.boolean(),
|
||||||
status: z.union([z.literal('authenticated'), z.literal('login-required')]),
|
status: z.union([z.literal('authenticated'), z.literal('login-required'), z.literal('removed')]),
|
||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
updatedAt: z.number(),
|
updatedAt: z.number(),
|
||||||
lastImportedAt: z.number(),
|
lastImportedAt: z.number(),
|
||||||
|
/** Set when the operator retired the account; its content is kept read-only. */
|
||||||
|
removedAt: z.number().nullable().optional(),
|
||||||
/** Absent on responses from a build that predates establishment tracking. */
|
/** Absent on responses from a build that predates establishment tracking. */
|
||||||
establishment: AccountEstablishmentSummarySchema.nullable().optional(),
|
establishment: AccountEstablishmentSummarySchema.nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
export type RedditAccountStatus = 'verified' | 'login-required' | 'disabled' | 'legacy-unassigned';
|
export type RedditAccountStatus =
|
||||||
|
| 'verified'
|
||||||
|
| 'login-required'
|
||||||
|
| 'disabled'
|
||||||
|
| 'legacy-unassigned'
|
||||||
|
/** Retired by the operator: content is kept and readable, credentials are gone. */
|
||||||
|
| 'removed';
|
||||||
|
|
||||||
export interface RedditAccountPublicRecord {
|
export interface RedditAccountPublicRecord {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@@ -12,6 +18,7 @@ export interface RedditAccountPublicRecord {
|
|||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
lastVerifiedAt: number | null;
|
lastVerifiedAt: number | null;
|
||||||
lastImportedAt: number | null;
|
lastImportedAt: number | null;
|
||||||
|
removedAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Private credential-bearing record. Never return this from a web API. */
|
/** Private credential-bearing record. Never return this from a web API. */
|
||||||
@@ -41,29 +48,16 @@ type AccountRow = {
|
|||||||
updated_at: number;
|
updated_at: number;
|
||||||
last_verified_at: number | null;
|
last_verified_at: number | null;
|
||||||
last_imported_at: number | null;
|
last_imported_at: number | null;
|
||||||
|
removed_at: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Tables whose foreign key to `reddit_accounts` predates cascading deletes,
|
|
||||||
* ordered children first so each delete leaves no dangling composite reference.
|
|
||||||
*/
|
|
||||||
const ACCOUNT_OWNED_TABLES = [
|
|
||||||
'conversation_outcomes',
|
|
||||||
'successful_messages',
|
|
||||||
'delivery_outbox',
|
|
||||||
'sent_messages',
|
|
||||||
'conversation_states',
|
|
||||||
'activity_log',
|
|
||||||
'processed_posts',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export class RedditAccountsRepository {
|
export class RedditAccountsRepository {
|
||||||
constructor(private readonly db: Database.Database) {}
|
constructor(private readonly db: Database.Database) {}
|
||||||
|
|
||||||
listPublic(): RedditAccountPublicRecord[] {
|
listPublic(): RedditAccountPublicRecord[] {
|
||||||
const rows = this.db.prepare(`
|
const rows = this.db.prepare(`
|
||||||
SELECT account_id, reddit_username, matrix_user_id, status, is_active,
|
SELECT account_id, reddit_username, matrix_user_id, status, is_active,
|
||||||
created_at, updated_at, last_verified_at, last_imported_at
|
created_at, updated_at, last_verified_at, last_imported_at, removed_at
|
||||||
FROM reddit_accounts
|
FROM reddit_accounts
|
||||||
ORDER BY is_active DESC, reddit_username COLLATE NOCASE, account_id
|
ORDER BY is_active DESC, reddit_username COLLATE NOCASE, account_id
|
||||||
`).all() as AccountRow[];
|
`).all() as AccountRow[];
|
||||||
@@ -80,7 +74,7 @@ export class RedditAccountsRepository {
|
|||||||
getActive(): RedditAccountPublicRecord[] {
|
getActive(): RedditAccountPublicRecord[] {
|
||||||
const rows = this.db.prepare(`
|
const rows = this.db.prepare(`
|
||||||
SELECT account_id, reddit_username, matrix_user_id, status, is_active,
|
SELECT account_id, reddit_username, matrix_user_id, status, is_active,
|
||||||
created_at, updated_at, last_verified_at, last_imported_at
|
created_at, updated_at, last_verified_at, last_imported_at, removed_at
|
||||||
FROM reddit_accounts
|
FROM reddit_accounts
|
||||||
WHERE is_active = 1 AND status != 'legacy-unassigned'
|
WHERE is_active = 1 AND status != 'legacy-unassigned'
|
||||||
ORDER BY reddit_username COLLATE NOCASE, account_id
|
ORDER BY reddit_username COLLATE NOCASE, account_id
|
||||||
@@ -113,7 +107,8 @@ export class RedditAccountsRepository {
|
|||||||
matrix_credentials_json = excluded.matrix_credentials_json,
|
matrix_credentials_json = excluded.matrix_credentials_json,
|
||||||
updated_at = excluded.updated_at,
|
updated_at = excluded.updated_at,
|
||||||
last_verified_at = excluded.last_verified_at,
|
last_verified_at = excluded.last_verified_at,
|
||||||
last_imported_at = excluded.last_imported_at
|
last_imported_at = excluded.last_imported_at,
|
||||||
|
removed_at = NULL
|
||||||
`).run({ ...input, now });
|
`).run({ ...input, now });
|
||||||
return publicFromPrivate(this.getRequired(input.accountId));
|
return publicFromPrivate(this.getRequired(input.accountId));
|
||||||
}
|
}
|
||||||
@@ -129,7 +124,7 @@ export class RedditAccountsRepository {
|
|||||||
return this.db.prepare(`
|
return this.db.prepare(`
|
||||||
UPDATE reddit_accounts
|
UPDATE reddit_accounts
|
||||||
SET matrix_credentials_json = ?, updated_at = ?, last_verified_at = ?
|
SET matrix_credentials_json = ?, updated_at = ?, last_verified_at = ?
|
||||||
WHERE account_id = ? AND status != 'legacy-unassigned'
|
WHERE account_id = ? AND status NOT IN ('legacy-unassigned', 'removed')
|
||||||
`).run(matrixCredentialsJson, now, now, accountId).changes === 1;
|
`).run(matrixCredentialsJson, now, now, accountId).changes === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,32 +133,31 @@ export class RedditAccountsRepository {
|
|||||||
return this.db.prepare(`
|
return this.db.prepare(`
|
||||||
UPDATE reddit_accounts
|
UPDATE reddit_accounts
|
||||||
SET is_active = ?, status = ?, updated_at = ?
|
SET is_active = ?, status = ?, updated_at = ?
|
||||||
WHERE account_id = ? AND status != 'legacy-unassigned'
|
WHERE account_id = ? AND status NOT IN ('legacy-unassigned', 'removed')
|
||||||
`).run(active ? 1 : 0, status, now, accountId).changes === 1;
|
`).run(active ? 1 : 0, status, now, accountId).changes === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the account and everything it owns. Account-scoped tables added
|
* Retire the account. Everything it collected stays — the row survives so
|
||||||
* after the ownership migration cascade on their own, but the tables that
|
* conversations and media keep an owner — but the stored credentials are
|
||||||
* migration created reference `reddit_accounts` with no delete action, so
|
* destroyed, which is what makes a later send or sync impossible rather
|
||||||
* their rows are cleared explicitly — children before parents — or SQLite
|
* than merely disabled.
|
||||||
* would refuse the delete.
|
|
||||||
*/
|
*/
|
||||||
delete(accountId: string): boolean {
|
retire(accountId: string, now: number = Date.now()): boolean {
|
||||||
requireText(accountId, 'accountId');
|
requireText(accountId, 'accountId');
|
||||||
return this.db.transaction((id: string) => {
|
return this.db.prepare(`
|
||||||
for (const table of ACCOUNT_OWNED_TABLES) {
|
UPDATE reddit_accounts
|
||||||
this.db.prepare(`DELETE FROM ${table} WHERE account_id = ?`).run(id);
|
SET status = 'removed', is_active = 0, cookies_json = '[]', matrix_credentials_json = '{}',
|
||||||
}
|
removed_at = ?, updated_at = ?
|
||||||
return this.db.prepare('DELETE FROM reddit_accounts WHERE account_id = ?').run(id).changes === 1;
|
WHERE account_id = ? AND status NOT IN ('legacy-unassigned', 'removed')
|
||||||
})(accountId);
|
`).run(now, now, accountId).changes === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
markLoginRequired(accountId: string, now: number = Date.now()): boolean {
|
markLoginRequired(accountId: string, now: number = Date.now()): boolean {
|
||||||
return this.db.prepare(`
|
return this.db.prepare(`
|
||||||
UPDATE reddit_accounts
|
UPDATE reddit_accounts
|
||||||
SET status = 'login-required', updated_at = ?
|
SET status = 'login-required', updated_at = ?
|
||||||
WHERE account_id = ? AND status != 'legacy-unassigned'
|
WHERE account_id = ? AND status NOT IN ('legacy-unassigned', 'removed')
|
||||||
`).run(now, accountId).changes === 1;
|
`).run(now, accountId).changes === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +179,7 @@ function mapPublicAccount(row: AccountRow): RedditAccountPublicRecord {
|
|||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
lastVerifiedAt: row.last_verified_at,
|
lastVerifiedAt: row.last_verified_at,
|
||||||
lastImportedAt: row.last_imported_at,
|
lastImportedAt: row.last_imported_at,
|
||||||
|
removedAt: row.removed_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-3
@@ -5,6 +5,9 @@ import fs from 'fs';
|
|||||||
|
|
||||||
export const LEGACY_UNASSIGNED_ACCOUNT_ID = '__legacy_unassigned__';
|
export const LEGACY_UNASSIGNED_ACCOUNT_ID = '__legacy_unassigned__';
|
||||||
|
|
||||||
|
/** Migrations that replace a table other tables reference by foreign key. */
|
||||||
|
const FOREIGN_KEY_REBUILD_VERSIONS = new Set([11, 32]);
|
||||||
|
|
||||||
export function createDatabase(config: Pick<Config, 'database'>): Database.Database {
|
export function createDatabase(config: Pick<Config, 'database'>): Database.Database {
|
||||||
const dbPath = config.database.path === ':memory:'
|
const dbPath = config.database.path === ':memory:'
|
||||||
? ':memory:'
|
? ':memory:'
|
||||||
@@ -772,6 +775,40 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
ON learning_evaluation_runs(created_at DESC);
|
ON learning_evaluation_runs(created_at DESC);
|
||||||
`);
|
`);
|
||||||
},
|
},
|
||||||
|
// Removing an account retires it rather than deleting it: the content it
|
||||||
|
// collected stays readable and attributed, so the row has to survive with
|
||||||
|
// a status that keeps it out of every runtime and sending path.
|
||||||
|
() => db.exec(`
|
||||||
|
CREATE TABLE reddit_accounts_with_removed (
|
||||||
|
account_id TEXT PRIMARY KEY,
|
||||||
|
reddit_username TEXT NOT NULL,
|
||||||
|
matrix_user_id TEXT UNIQUE,
|
||||||
|
status TEXT NOT NULL CHECK(status IN (
|
||||||
|
'verified', 'login-required', 'disabled', 'legacy-unassigned', 'removed'
|
||||||
|
)),
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1 CHECK(is_active IN (0, 1)),
|
||||||
|
cookies_json TEXT NOT NULL,
|
||||||
|
matrix_credentials_json TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
last_verified_at INTEGER,
|
||||||
|
last_imported_at INTEGER,
|
||||||
|
removed_at INTEGER
|
||||||
|
);
|
||||||
|
INSERT INTO reddit_accounts_with_removed (
|
||||||
|
account_id, reddit_username, matrix_user_id, status, is_active,
|
||||||
|
cookies_json, matrix_credentials_json, created_at, updated_at,
|
||||||
|
last_verified_at, last_imported_at, removed_at
|
||||||
|
) SELECT
|
||||||
|
account_id, reddit_username, matrix_user_id, status, is_active,
|
||||||
|
cookies_json, matrix_credentials_json, created_at, updated_at,
|
||||||
|
last_verified_at, last_imported_at, NULL
|
||||||
|
FROM reddit_accounts;
|
||||||
|
DROP TABLE reddit_accounts;
|
||||||
|
ALTER TABLE reddit_accounts_with_removed RENAME TO reddit_accounts;
|
||||||
|
CREATE INDEX idx_reddit_accounts_username ON reddit_accounts(reddit_username COLLATE NOCASE);
|
||||||
|
CREATE INDEX idx_reddit_accounts_active ON reddit_accounts(is_active, status);
|
||||||
|
`),
|
||||||
];
|
];
|
||||||
|
|
||||||
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
||||||
@@ -782,9 +819,10 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
for (let index = currentVersion; index < migrations.length; index += 1) {
|
for (let index = currentVersion; index < migrations.length; index += 1) {
|
||||||
const version = index + 1;
|
const version = index + 1;
|
||||||
// SQLite cannot rebuild a referenced table while FK enforcement is on.
|
// SQLite cannot rebuild a referenced table while FK enforcement is on.
|
||||||
// Migration 11 replaces the account-produced table graph atomically, then
|
// Migration 11 replaces the account-produced table graph atomically and
|
||||||
// validates it before foreign-key enforcement is restored.
|
// migration 32 rebuilds `reddit_accounts` itself; both validate the graph
|
||||||
const rebuildsForeignKeyGraph = version === 11;
|
// before foreign-key enforcement is restored.
|
||||||
|
const rebuildsForeignKeyGraph = FOREIGN_KEY_REBUILD_VERSIONS.has(version);
|
||||||
if (rebuildsForeignKeyGraph) db.pragma('foreign_keys = OFF');
|
if (rebuildsForeignKeyGraph) db.pragma('foreign_keys = OFF');
|
||||||
try {
|
try {
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
|
|||||||
@@ -50,10 +50,13 @@ export interface RedditAccountSummary {
|
|||||||
redditUsername: string;
|
redditUsername: string;
|
||||||
matrixUserId: string;
|
matrixUserId: string;
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
status: 'authenticated' | 'login-required';
|
/** `removed` is terminal until a fresh session is imported for the account. */
|
||||||
|
status: 'authenticated' | 'login-required' | 'removed';
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
lastImportedAt: number;
|
lastImportedAt: number;
|
||||||
|
/** When the operator retired the account. Null while it is in use. */
|
||||||
|
removedAt: number | null;
|
||||||
/** Null until the establishment check has run for this account. */
|
/** Null until the establishment check has run for this account. */
|
||||||
establishment: RedditAccountEstablishmentSummary | null;
|
establishment: RedditAccountEstablishmentSummary | null;
|
||||||
}
|
}
|
||||||
@@ -78,6 +81,11 @@ export class RedditAccountsService {
|
|||||||
return this.list().find((account) => account.accountId === accountId) ?? null;
|
return this.list().find((account) => account.accountId === accountId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retired accounts, whose collected content is still served read-only. */
|
||||||
|
listRemoved(): RedditAccountSummary[] {
|
||||||
|
return this.list().filter((account) => account.status === 'removed');
|
||||||
|
}
|
||||||
|
|
||||||
async activate(accountId: string): Promise<RedditAccountSummary> {
|
async activate(accountId: string): Promise<RedditAccountSummary> {
|
||||||
const stored = this.accounts.get(accountId);
|
const stored = this.accounts.get(accountId);
|
||||||
if (!stored || stored.status === 'legacy-unassigned') {
|
if (!stored || stored.status === 'legacy-unassigned') {
|
||||||
@@ -91,19 +99,23 @@ export class RedditAccountsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop the account's loop and drop it, along with the content it owns, from
|
* Retire the account: stop its loop and destroy its credentials, but keep
|
||||||
* the database. Downloaded media files stay on disk: the media library can
|
* every conversation, message and media item it collected. The content stays
|
||||||
* hand an image to another account, so the files are not ours alone to bin.
|
* readable and attributed; with no session left there is nothing to sync and
|
||||||
|
* nothing to send with.
|
||||||
*/
|
*/
|
||||||
async remove(accountId: string): Promise<RedditAccountSummary> {
|
async remove(accountId: string): Promise<RedditAccountSummary> {
|
||||||
const account = this.get(accountId);
|
const account = this.get(accountId);
|
||||||
if (!account) throw new Error('Reddit account not found');
|
if (!account) throw new Error('Reddit account not found');
|
||||||
|
if (account.status === 'removed') throw new Error('Reddit account was already removed');
|
||||||
await this.runtimes?.stop(accountId);
|
await this.runtimes?.stop(accountId);
|
||||||
if (!this.accounts.delete(accountId)) throw new Error('Reddit account not found');
|
if (!this.accounts.retire(accountId)) throw new Error('Reddit account not found');
|
||||||
if (this.auth.getSnapshot().accountId === accountId) {
|
if (this.auth.getSnapshot().accountId === accountId) {
|
||||||
this.auth.invalidate(`Reddit account u/${account.redditUsername} was removed`);
|
this.auth.invalidate(`Reddit account u/${account.redditUsername} was removed`);
|
||||||
}
|
}
|
||||||
return { ...account, isRunning: false, status: 'login-required' };
|
const retired = this.get(accountId);
|
||||||
|
if (!retired) throw new Error('Removed Reddit account was not persisted');
|
||||||
|
return retired;
|
||||||
}
|
}
|
||||||
|
|
||||||
async importSession(cookies: Cookie[], credentials: MatrixCredentials): Promise<RedditAccountSummary> {
|
async importSession(cookies: Cookie[], credentials: MatrixCredentials): Promise<RedditAccountSummary> {
|
||||||
@@ -124,18 +136,23 @@ export class RedditAccountsService {
|
|||||||
|
|
||||||
private toSummary(account: RedditAccountPublicRecord): RedditAccountSummary {
|
private toSummary(account: RedditAccountPublicRecord): RedditAccountSummary {
|
||||||
const runtime = this.runtimes?.get(account.accountId);
|
const runtime = this.runtimes?.get(account.accountId);
|
||||||
const running = runtime?.snapshot().state === 'running';
|
const running = account.status === 'removed' ? false : runtime?.snapshot().state === 'running';
|
||||||
return {
|
return {
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
redditUsername: account.redditUsername,
|
redditUsername: account.redditUsername,
|
||||||
matrixUserId: account.matrixUserId ?? account.accountId,
|
matrixUserId: account.matrixUserId ?? account.accountId,
|
||||||
isRunning: running ?? (account.accountId === this.auth.getSnapshot().accountId),
|
isRunning: account.status === 'removed'
|
||||||
status: account.status === 'verified' && (running ?? account.accountId === this.auth.getSnapshot().accountId)
|
? false
|
||||||
|
: running ?? (account.accountId === this.auth.getSnapshot().accountId),
|
||||||
|
status: account.status === 'removed'
|
||||||
|
? 'removed'
|
||||||
|
: account.status === 'verified' && (running ?? account.accountId === this.auth.getSnapshot().accountId)
|
||||||
? 'authenticated'
|
? 'authenticated'
|
||||||
: 'login-required',
|
: 'login-required',
|
||||||
createdAt: account.createdAt,
|
createdAt: account.createdAt,
|
||||||
updatedAt: account.updatedAt,
|
updatedAt: account.updatedAt,
|
||||||
lastImportedAt: account.lastImportedAt ?? account.updatedAt,
|
lastImportedAt: account.lastImportedAt ?? account.updatedAt,
|
||||||
|
removedAt: account.removedAt,
|
||||||
establishment: this.toEstablishmentSummary(account.accountId),
|
establishment: this.toEstablishmentSummary(account.accountId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,11 +39,16 @@ const BulkMarkReadRequestSchema = z.strictObject({
|
|||||||
conversationIds: z.array(z.string().min(1)).min(1).max(500),
|
conversationIds: z.array(z.string().min(1)).min(1).max(500),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const READ_ONLY_MESSAGE =
|
||||||
|
'This conversation belongs to a removed account. Its history is kept, but nothing can be sent or synced from it.';
|
||||||
|
|
||||||
type OwnedActiveChat = ActiveChat & {
|
type OwnedActiveChat = ActiveChat & {
|
||||||
owner?: { accountId: string; redditUsername: string };
|
owner?: { accountId: string; redditUsername: string };
|
||||||
automationStatus?: ConversationAutomationStatus;
|
automationStatus?: ConversationAutomationStatus;
|
||||||
automationPolicyMode?: ConversationPolicyMode;
|
automationPolicyMode?: ConversationPolicyMode;
|
||||||
automationHandoffReason?: string | null;
|
automationHandoffReason?: string | null;
|
||||||
|
/** Owned by a retired account: readable, but nothing can be sent into it. */
|
||||||
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** An owned conversation with operator triage state projected onto it. */
|
/** An owned conversation with operator triage state projected onto it. */
|
||||||
@@ -119,12 +124,48 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conversations collected by retired accounts. There is no runtime to read
|
||||||
|
* them from, so they come straight from the account content table — the same
|
||||||
|
* payloads a running account serves out of its own cache.
|
||||||
|
*/
|
||||||
|
function retiredConversations(accountId: string): ActiveChat[] {
|
||||||
|
const store = ctx.repos.accountContent;
|
||||||
|
if (!store) return [];
|
||||||
|
return store.listConversations(accountId).flatMap((row) => {
|
||||||
|
try { return [JSON.parse(row.payloadJson) as ActiveChat]; } catch { return []; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function retiredAccount(accountId: string) {
|
||||||
|
return ctx.services.redditAccounts?.listRemoved().find((account) => account.accountId === accountId);
|
||||||
|
}
|
||||||
|
|
||||||
async function findConversation(id: string): Promise<{
|
async function findConversation(id: string): Promise<{
|
||||||
conversation: ActiveChat;
|
conversation: ActiveChat;
|
||||||
runtime: NonNullable<ReturnType<NonNullable<WebContext['services']['accountRuntimes']>['get']>> | null;
|
runtime: NonNullable<ReturnType<NonNullable<WebContext['services']['accountRuntimes']>['get']>> | null;
|
||||||
|
/** The owning account was retired: serve the content, refuse every write. */
|
||||||
|
readOnly?: { accountId: string; redditUsername: string };
|
||||||
} | undefined> {
|
} | undefined> {
|
||||||
const owned = parseOwnedId(id);
|
const owned = parseOwnedId(id);
|
||||||
if (!owned && (ctx.services.accountRuntimes?.list().length ?? 0) > 0) return undefined;
|
if (!owned && (ctx.services.accountRuntimes?.list().length ?? 0) > 0) return undefined;
|
||||||
|
// A retired account has no runtime by design, so it is resolved from the
|
||||||
|
// archive before the runtime requirement below would reject it.
|
||||||
|
const retired = owned ? retiredAccount(owned.accountId) : undefined;
|
||||||
|
if (owned && retired) {
|
||||||
|
const archived = retiredConversations(owned.accountId).find(
|
||||||
|
(chat) => chat.roomId === owned.conversationId
|
||||||
|
|| chat.username === owned.conversationId
|
||||||
|
|| chat.roomName === owned.conversationId,
|
||||||
|
);
|
||||||
|
return archived
|
||||||
|
? {
|
||||||
|
conversation: archived,
|
||||||
|
runtime: null,
|
||||||
|
readOnly: { accountId: retired.accountId, redditUsername: retired.redditUsername },
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
const runtime = owned ? ctx.services.accountRuntimes?.get(owned.accountId) ?? null : null;
|
const runtime = owned ? ctx.services.accountRuntimes?.get(owned.accountId) ?? null : null;
|
||||||
if (owned && (!runtime || runtime.snapshot().state !== 'running')) return undefined;
|
if (owned && (!runtime || runtime.snapshot().state !== 'running')) return undefined;
|
||||||
const chat = runtime?.resources.chatService ?? ctx.services.chat;
|
const chat = runtime?.resources.chatService ?? ctx.services.chat;
|
||||||
@@ -137,6 +178,21 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
return conversation ? { conversation, runtime } : undefined;
|
return conversation ? { conversation, runtime } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conversation state derived purely from the stored transcript. A retired
|
||||||
|
* account owns no state repository, and borrowing another account's would
|
||||||
|
* both read and write the wrong rows.
|
||||||
|
*/
|
||||||
|
function archivedIntelligence(conversation: ActiveChat) {
|
||||||
|
const messages = (conversation.messages ?? []).map((message) => ({
|
||||||
|
sender: message.sender,
|
||||||
|
text: message.text,
|
||||||
|
isSelf: message.isSelf,
|
||||||
|
}));
|
||||||
|
const state = buildConversationState(messages);
|
||||||
|
return { messages, state, intent: chooseConversationIntent(messages, state) };
|
||||||
|
}
|
||||||
|
|
||||||
function getConversationIntelligence(
|
function getConversationIntelligence(
|
||||||
conversation: ActiveChat,
|
conversation: ActiveChat,
|
||||||
stateRepo = ctx.services.conversationState,
|
stateRepo = ctx.services.conversationState,
|
||||||
@@ -231,6 +287,28 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
conversation.roomId ? readState.get(conversation.roomId) : undefined,
|
conversation.roomId ? readState.get(conversation.roomId) : undefined,
|
||||||
));
|
));
|
||||||
})();
|
})();
|
||||||
|
// Retired accounts keep everything they collected. Their rows are marked
|
||||||
|
// read-only and never flagged as needing a reply — the operator has no
|
||||||
|
// way to answer them, so they must not sit in the actionable queue.
|
||||||
|
for (const account of ctx.services.redditAccounts?.listRemoved() ?? []) {
|
||||||
|
for (const archived of retiredConversations(account.accountId)) {
|
||||||
|
// Built without the shared state repositories on purpose: those are
|
||||||
|
// scoped to a live account, and a retired one owns none of them.
|
||||||
|
conversations.push({
|
||||||
|
...archived,
|
||||||
|
owner: { accountId: account.accountId, redditUsername: account.redditUsername },
|
||||||
|
readOnly: true,
|
||||||
|
hasUnreadInbound: false,
|
||||||
|
needsReply: false,
|
||||||
|
// Deliberately left open rather than resolved: the default inbox
|
||||||
|
// filter hides resolved conversations, and archived history is
|
||||||
|
// meant to stay browsable there.
|
||||||
|
triageStatus: 'open',
|
||||||
|
snoozedUntil: null,
|
||||||
|
lastReadMessageTs: archived.lastMessageTime ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
if (req.query.includeImageMetadata !== 'true') {
|
if (req.query.includeImageMetadata !== 'true') {
|
||||||
res.json(conversations.map((conversation) => ({ ...conversation, owner: conversation.owner ?? currentOwner() })));
|
res.json(conversations.map((conversation) => ({ ...conversation, owner: conversation.owner ?? currentOwner() })));
|
||||||
return;
|
return;
|
||||||
@@ -299,8 +377,11 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
? conversation.messages[conversation.messages.length - 1].timestamp
|
? conversation.messages[conversation.messages.length - 1].timestamp
|
||||||
: 0;
|
: 0;
|
||||||
const hasNewerActivity = (conversation.lastMessageTime ?? 0) > cachedLastTs;
|
const hasNewerActivity = (conversation.lastMessageTime ?? 0) > cachedLastTs;
|
||||||
const needsLazyLoad =
|
// A retired conversation is whatever was captured before removal. There
|
||||||
!conversation.messages || conversation.messages.length === 0 || hasNewerActivity;
|
// is no session left to fetch more with, and the fallback chat service
|
||||||
|
// belongs to a different account, so lazy-loading is off the table.
|
||||||
|
const needsLazyLoad = !resolved.readOnly
|
||||||
|
&& (!conversation.messages || conversation.messages.length === 0 || hasNewerActivity);
|
||||||
if (needsLazyLoad && !syncInFlight) {
|
if (needsLazyLoad && !syncInFlight) {
|
||||||
try {
|
try {
|
||||||
await chatService.loadConversationDetails(conversation);
|
await chatService.loadConversationDetails(conversation);
|
||||||
@@ -346,8 +427,12 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
ctx.logger.warn(`Failed to look up triggering post for ${conversation.username}:`, err);
|
ctx.logger.warn(`Failed to look up triggering post for ${conversation.username}:`, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stateRepo = resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState;
|
const stateRepo = resolved.readOnly
|
||||||
const intelligence = getConversationIntelligence(conversation, stateRepo);
|
? undefined
|
||||||
|
: resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState;
|
||||||
|
const intelligence = resolved.readOnly
|
||||||
|
? archivedIntelligence(conversation)
|
||||||
|
: getConversationIntelligence(conversation, stateRepo);
|
||||||
const receivedImage = (conversation.messages ?? []).some(
|
const receivedImage = (conversation.messages ?? []).some(
|
||||||
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
|
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
|
||||||
);
|
);
|
||||||
@@ -375,7 +460,9 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
return { ...message, isAi: true };
|
return { ...message, isAi: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
const readStateRepo = resolved.runtime?.resources.conversationReadStateRepo ?? ctx.services.conversationReadState;
|
const readStateRepo = resolved.readOnly
|
||||||
|
? undefined
|
||||||
|
: resolved.runtime?.resources.conversationReadStateRepo ?? ctx.services.conversationReadState;
|
||||||
readStateRepo?.expireSnoozes();
|
readStateRepo?.expireSnoozes();
|
||||||
const triage = annotateTriage(
|
const triage = annotateTriage(
|
||||||
conversation,
|
conversation,
|
||||||
@@ -394,9 +481,11 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
recipientAccountStatusCheckedAt: triage.recipientAccountStatusCheckedAt,
|
recipientAccountStatusCheckedAt: triage.recipientAccountStatusCheckedAt,
|
||||||
sellerDetected: intelligence.state.sellerDetected,
|
sellerDetected: intelligence.state.sellerDetected,
|
||||||
...(annotatedMessages ? { messages: annotatedMessages } : {}),
|
...(annotatedMessages ? { messages: annotatedMessages } : {}),
|
||||||
owner: resolved.runtime
|
owner: resolved.readOnly
|
||||||
|
?? (resolved.runtime
|
||||||
? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername }
|
? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername }
|
||||||
: currentOwner(),
|
: currentOwner()),
|
||||||
|
...(resolved.readOnly ? { readOnly: true } : {}),
|
||||||
triggeringPost,
|
triggeringPost,
|
||||||
automation: {
|
automation: {
|
||||||
turnsUsed: intelligence.state.automation.turnsUsed,
|
turnsUsed: intelligence.state.automation.turnsUsed,
|
||||||
@@ -433,6 +522,10 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
res.status(404).json({ error: 'Conversation not found' });
|
res.status(404).json({ error: 'Conversation not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (resolved.readOnly) {
|
||||||
|
res.status(409).json({ error: READ_ONLY_MESSAGE });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const conversation = resolved.conversation;
|
const conversation = resolved.conversation;
|
||||||
if (!conversation.messages || conversation.messages.length === 0) {
|
if (!conversation.messages || conversation.messages.length === 0) {
|
||||||
res.status(400).json({ error: 'No messages in conversation to generate a reply from' });
|
res.status(400).json({ error: 'No messages in conversation to generate a reply from' });
|
||||||
@@ -472,6 +565,10 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
res.status(404).json({ error: 'Conversation not found' });
|
res.status(404).json({ error: 'Conversation not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (resolved.readOnly) {
|
||||||
|
res.status(409).json({ error: READ_ONLY_MESSAGE });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const conversation = resolved.conversation;
|
const conversation = resolved.conversation;
|
||||||
if (!conversation.messages || conversation.messages.length === 0) {
|
if (!conversation.messages || conversation.messages.length === 0) {
|
||||||
res.status(400).json({ error: 'No messages in conversation to generate from' });
|
res.status(400).json({ error: 'No messages in conversation to generate from' });
|
||||||
@@ -517,6 +614,10 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
res.status(404).json({ error: 'Conversation not found' });
|
res.status(404).json({ error: 'Conversation not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (resolved.readOnly) {
|
||||||
|
res.status(409).json({ error: READ_ONLY_MESSAGE });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const conversation = resolved.conversation;
|
const conversation = resolved.conversation;
|
||||||
const chatService = resolved.runtime?.resources.chatService ?? ctx.services.chat;
|
const chatService = resolved.runtime?.resources.chatService ?? ctx.services.chat;
|
||||||
const stateRepo = resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState;
|
const stateRepo = resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState;
|
||||||
@@ -605,6 +706,7 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
> {
|
> {
|
||||||
const resolved = await findConversation(decodeURIComponent(id));
|
const resolved = await findConversation(decodeURIComponent(id));
|
||||||
if (!resolved) return { error: { status: 404, message: 'Conversation not found' } };
|
if (!resolved) return { error: { status: 404, message: 'Conversation not found' } };
|
||||||
|
if (resolved.readOnly) return { error: { status: 409, message: READ_ONLY_MESSAGE } };
|
||||||
const repo = resolved.runtime?.resources.conversationReadStateRepo ?? ctx.services.conversationReadState;
|
const repo = resolved.runtime?.resources.conversationReadStateRepo ?? ctx.services.conversationReadState;
|
||||||
if (!repo) return { error: { status: 503, message: 'Conversation read state is not initialized' } };
|
if (!repo) return { error: { status: 503, message: 'Conversation read state is not initialized' } };
|
||||||
if (!resolved.conversation.roomId) {
|
if (!resolved.conversation.roomId) {
|
||||||
@@ -828,6 +930,10 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
if (!conversation.roomId) {
|
if (!conversation.roomId) {
|
||||||
return res.json({ requestKind: null, assets: [], reason: 'This conversation has no Matrix room.', warning: null });
|
return res.json({ requestKind: null, assets: [], reason: 'This conversation has no Matrix room.', warning: null });
|
||||||
}
|
}
|
||||||
|
if (resolved.readOnly) {
|
||||||
|
res.status(409).json({ error: READ_ONLY_MESSAGE });
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Full manual control: every enabled, unused library image is offered
|
// Full manual control: every enabled, unused library image is offered
|
||||||
// regardless of what (if anything) the latest message asked for. Safety
|
// regardless of what (if anything) the latest message asked for. Safety
|
||||||
// flags surface as a non-blocking warning, never a gate.
|
// flags surface as a non-blocking warning, never a gate.
|
||||||
@@ -853,6 +959,7 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
try {
|
try {
|
||||||
const resolved = await findConversation(decodeURIComponent(req.params.id));
|
const resolved = await findConversation(decodeURIComponent(req.params.id));
|
||||||
if (!resolved) return res.status(404).json({ error: 'Conversation not found' });
|
if (!resolved) return res.status(404).json({ error: 'Conversation not found' });
|
||||||
|
if (resolved.readOnly) return res.status(409).json({ error: READ_ONLY_MESSAGE });
|
||||||
const runtime = resolved.runtime;
|
const runtime = resolved.runtime;
|
||||||
const exchange = runtime?.resources.mediaExchange;
|
const exchange = runtime?.resources.mediaExchange;
|
||||||
if (!runtime || !exchange) return res.status(503).json({ error: 'Media sharing is not initialized for this account' });
|
if (!runtime || !exchange) return res.status(503).json({ error: 'Media sharing is not initialized for this account' });
|
||||||
|
|||||||
@@ -334,10 +334,10 @@ describe('browsing migration', () => {
|
|||||||
const db = new Database(':memory:');
|
const db = new Database(':memory:');
|
||||||
db.pragma('foreign_keys = ON');
|
db.pragma('foreign_keys = ON');
|
||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
const version = db.pragma('user_version', { simple: true }) as number;
|
|
||||||
db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;');
|
db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;');
|
||||||
// Five browsing migrations plus the later conversation-learning migration.
|
// Absolute, not counted back from the head: browsing's first migration is
|
||||||
db.pragma(`user_version = ${version - 6}`);
|
// 26, and a relative rewind breaks whenever a migration is appended.
|
||||||
|
db.pragma('user_version = 25');
|
||||||
|
|
||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
insertAccount(db, ACCOUNT, 'owner');
|
insertAccount(db, ACCOUNT, 'owner');
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ describe('multi-account database ownership', () => {
|
|||||||
expect(db.pragma('foreign_key_check')).toEqual([]);
|
expect(db.pragma('foreign_key_check')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes an account with everything it owns and leaves other accounts intact', () => {
|
it('retires an account while keeping every row it collected', () => {
|
||||||
addAccount('t2_alice', 'alice-bot', 100);
|
addAccount('t2_alice', 'alice-bot', 100);
|
||||||
addAccount('t2_bob', 'bob-bot', 110);
|
addAccount('t2_bob', 'bob-bot', 110);
|
||||||
|
|
||||||
@@ -174,28 +174,51 @@ describe('multi-account database ownership', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(accounts.delete('t2_alice')).toBe(true);
|
expect(accounts.retire('t2_alice', 500)).toBe(true);
|
||||||
|
|
||||||
expect(accounts.get('t2_alice')).toBeNull();
|
// The content the account collected is the point of retiring rather than
|
||||||
expect(accounts.listPublic().map((account) => account.accountId)).toContain('t2_bob');
|
// deleting: every row it produced must survive untouched.
|
||||||
for (const table of [
|
for (const table of [
|
||||||
'processed_posts', 'sent_messages', 'successful_messages', 'delivery_outbox',
|
'processed_posts', 'sent_messages', 'successful_messages', 'delivery_outbox',
|
||||||
'activity_log', 'conversation_states', 'reddit_conversations', 'reddit_account_sync_state',
|
'activity_log', 'conversation_states', 'reddit_conversations', 'reddit_account_sync_state',
|
||||||
]) {
|
]) {
|
||||||
expect({ table, ...db.prepare(`SELECT COUNT(*) count FROM ${table} WHERE account_id = ?`).get('t2_alice') as object })
|
expect({ table, ...db.prepare(`SELECT COUNT(*) count FROM ${table} WHERE account_id = ?`).get('t2_alice') as object })
|
||||||
.toEqual({ table, count: 0 });
|
|
||||||
expect({ table, ...db.prepare(`SELECT COUNT(*) count FROM ${table} WHERE account_id = ?`).get('t2_bob') as object })
|
|
||||||
.toEqual({ table, count: 1 });
|
.toEqual({ table, count: 1 });
|
||||||
}
|
}
|
||||||
|
expect(accounts.get('t2_alice')).toMatchObject({
|
||||||
|
status: 'removed', isActive: false, removedAt: 500,
|
||||||
|
});
|
||||||
|
// Credentials are destroyed, so no session survives to sync or send with.
|
||||||
|
expect(accounts.get('t2_alice')).toMatchObject({ cookiesJson: '[]', matrixCredentialsJson: '{}' });
|
||||||
|
expect(accounts.getActive().map((account) => account.accountId)).toEqual(['t2_bob']);
|
||||||
expect(db.pragma('foreign_key_check')).toEqual([]);
|
expect(db.pragma('foreign_key_check')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports an unknown account as not removed and rejects a blank id', () => {
|
it('refuses to reactivate or refresh a retired account', () => {
|
||||||
|
addAccount('t2_alice', 'alice-bot', 100);
|
||||||
|
expect(accounts.retire('t2_alice', 500)).toBe(true);
|
||||||
|
|
||||||
|
expect(accounts.setActive('t2_alice', true, 600)).toBe(false);
|
||||||
|
expect(accounts.markLoginRequired('t2_alice', 600)).toBe(false);
|
||||||
|
expect(accounts.updateMatrixCredentials('t2_alice', JSON.stringify({ accessToken: 'new' }), 600)).toBe(false);
|
||||||
|
expect(accounts.get('t2_alice')).toMatchObject({ status: 'removed', matrixCredentialsJson: '{}' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revives a retired account when a fresh session is imported for it', () => {
|
||||||
|
addAccount('t2_alice', 'alice-bot', 100);
|
||||||
|
accounts.retire('t2_alice', 500);
|
||||||
|
|
||||||
|
const revived = addAccount('t2_alice', 'alice-bot', 700);
|
||||||
|
|
||||||
|
expect(revived).toMatchObject({ status: 'verified', isActive: true, removedAt: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an unknown account as not retired and rejects a blank id', () => {
|
||||||
addAccount('t2_alice', 'alice-bot', 100);
|
addAccount('t2_alice', 'alice-bot', 100);
|
||||||
|
|
||||||
expect(accounts.delete('t2_missing')).toBe(false);
|
expect(accounts.retire('t2_missing')).toBe(false);
|
||||||
expect(() => accounts.delete(' ')).toThrow(/accountId/);
|
expect(() => accounts.retire(' ')).toThrow(/accountId/);
|
||||||
expect(accounts.get('t2_alice')).not.toBeNull();
|
expect(accounts.get('t2_alice')).toMatchObject({ status: 'verified' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('scopes legacy account-produced table uniqueness by account', () => {
|
it('scopes legacy account-produced table uniqueness by account', () => {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const OTHER_ACCOUNT = '@t2_other:reddit.com';
|
|||||||
// Browsing migrations depend on the growth tables, so a pre-growth fixture
|
// Browsing migrations depend on the growth tables, so a pre-growth fixture
|
||||||
// must rewind past both the growth sequence and the later browsing additions.
|
// must rewind past both the growth sequence and the later browsing additions.
|
||||||
const GROWTH_MIGRATION_COUNT = 7;
|
const GROWTH_MIGRATION_COUNT = 7;
|
||||||
|
/** Schema version that first creates the growth tables. */
|
||||||
|
const FIRST_GROWTH_MIGRATION = 24;
|
||||||
|
|
||||||
function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
|
function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
|
||||||
return {
|
return {
|
||||||
@@ -507,14 +509,16 @@ describe('AccountGrowthRepository', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('growth migration', () => {
|
describe('growth migration', () => {
|
||||||
/** Rewinds past every growth migration so they replay from a pre-growth state. */
|
/**
|
||||||
|
* Rewinds past every growth migration so they replay from a pre-growth state.
|
||||||
|
* The target is absolute: growth's first migration is 24, and counting back
|
||||||
|
* from the head silently breaks every time a migration is appended.
|
||||||
|
*/
|
||||||
function rewindPastGrowth(db: Database.Database): void {
|
function rewindPastGrowth(db: Database.Database): void {
|
||||||
const version = db.pragma('user_version', { simple: true }) as number;
|
|
||||||
db.exec('DROP TABLE account_growth_comments');
|
db.exec('DROP TABLE account_growth_comments');
|
||||||
db.exec('DROP TABLE account_growth_subreddits');
|
db.exec('DROP TABLE account_growth_subreddits');
|
||||||
db.exec('DROP TABLE account_growth_settings');
|
db.exec('DROP TABLE account_growth_settings');
|
||||||
// Also rewind the later conversation-learning migration.
|
db.pragma(`user_version = ${FIRST_GROWTH_MIGRATION - 1}`);
|
||||||
db.pragma(`user_version = ${version - GROWTH_MIGRATION_COUNT - 1}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it('upgrades a database created before growth existed', () => {
|
it('upgrades a database created before growth existed', () => {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe('removing a Reddit account', () => {
|
|||||||
|
|
||||||
afterEach(() => db.close());
|
afterEach(() => db.close());
|
||||||
|
|
||||||
it('stops the runtime before dropping the account and its stored credentials', async () => {
|
it('stops the runtime and destroys the session while keeping the account', async () => {
|
||||||
const removed = await service.remove('t2_alice');
|
const removed = await service.remove('t2_alice');
|
||||||
|
|
||||||
expect(stopped).toEqual(['t2_alice']);
|
expect(stopped).toEqual(['t2_alice']);
|
||||||
@@ -56,10 +56,22 @@ describe('removing a Reddit account', () => {
|
|||||||
accountId: 't2_alice',
|
accountId: 't2_alice',
|
||||||
redditUsername: 'alice-bot',
|
redditUsername: 'alice-bot',
|
||||||
isRunning: false,
|
isRunning: false,
|
||||||
status: 'login-required',
|
status: 'removed',
|
||||||
});
|
});
|
||||||
expect(accounts.get('t2_alice')).toBeNull();
|
// Retired, not deleted: the row survives so its content keeps an owner.
|
||||||
expect(service.list()).toEqual([]);
|
expect(accounts.get('t2_alice')).toMatchObject({
|
||||||
|
status: 'removed', cookiesJson: '[]', matrixCredentialsJson: '{}',
|
||||||
|
});
|
||||||
|
expect(service.list().map((account) => account.accountId)).toEqual(['t2_alice']);
|
||||||
|
expect(service.listRemoved().map((account) => account.accountId)).toEqual(['t2_alice']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to remove an account twice', async () => {
|
||||||
|
await service.remove('t2_alice');
|
||||||
|
stopped = [];
|
||||||
|
|
||||||
|
await expect(service.remove('t2_alice')).rejects.toThrow('Reddit account was already removed');
|
||||||
|
expect(stopped).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops the authenticated session when the removed account held it', async () => {
|
it('drops the authenticated session when the removed account held it', async () => {
|
||||||
@@ -93,12 +105,15 @@ describe('removing a Reddit account', () => {
|
|||||||
await service.remove('t2_alice');
|
await service.remove('t2_alice');
|
||||||
|
|
||||||
expect(auth.getSnapshot()).toMatchObject({ status: 'authenticated', accountId: 't2_bob' });
|
expect(auth.getSnapshot()).toMatchObject({ status: 'authenticated', accountId: 't2_bob' });
|
||||||
expect(service.list().map((account) => account.accountId)).toEqual(['t2_bob']);
|
expect(service.listRemoved().map((account) => account.accountId)).toEqual(['t2_alice']);
|
||||||
|
// Bob is untouched by Alice's removal — still listed, still not retired.
|
||||||
|
expect(service.list().find((account) => account.accountId === 't2_bob')?.status)
|
||||||
|
.not.toBe('removed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refuses to remove an account it does not know, without stopping anything', async () => {
|
it('refuses to remove an account it does not know, without stopping anything', async () => {
|
||||||
await expect(service.remove('t2_missing')).rejects.toThrow('Reddit account not found');
|
await expect(service.remove('t2_missing')).rejects.toThrow('Reddit account not found');
|
||||||
expect(stopped).toEqual([]);
|
expect(stopped).toEqual([]);
|
||||||
expect(accounts.get('t2_alice')).not.toBeNull();
|
expect(accounts.get('t2_alice')).toMatchObject({ status: 'verified' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -880,4 +880,96 @@ describe('conversations route', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('removed accounts', () => {
|
||||||
|
const ARCHIVED = {
|
||||||
|
username: 'alice', roomName: 'alice', roomId: 'room-alice', lastMessageTime: 500,
|
||||||
|
lastMessageSnippet: 'thanks!', isFromUs: false, unread: false,
|
||||||
|
messages: [{ sender: 'alice', text: 'thanks!', timestamp: 500, isSelf: false }],
|
||||||
|
};
|
||||||
|
const OWNED_ID = `${encodeURIComponent('@t2_gone:reddit.com')}~${encodeURIComponent('room-alice')}`;
|
||||||
|
|
||||||
|
/** A retired account: listed by the service, but with no runtime at all. */
|
||||||
|
function retiredContext() {
|
||||||
|
return {
|
||||||
|
logger: { error: vi.fn(), warn: vi.fn() },
|
||||||
|
repos: {
|
||||||
|
accountContent: {
|
||||||
|
listConversations: vi.fn((accountId: string) => accountId === '@t2_gone:reddit.com'
|
||||||
|
? [{ conversationId: 'room-alice', payloadJson: JSON.stringify(ARCHIVED) }]
|
||||||
|
: []),
|
||||||
|
},
|
||||||
|
messages: { getByChatRoomId: vi.fn(), getLatestByRecipient: vi.fn() },
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
chat: {
|
||||||
|
getCachedConversations: vi.fn().mockResolvedValue([]),
|
||||||
|
getSyncStatus: () => ({ syncing: false }),
|
||||||
|
loadConversationDetails: vi.fn(),
|
||||||
|
saveCacheExternal: vi.fn(),
|
||||||
|
},
|
||||||
|
// Present so suggest-reply reaches the read-only guard rather than
|
||||||
|
// bailing out earlier on a missing AI client.
|
||||||
|
ai: { generateReply: vi.fn() },
|
||||||
|
accountRuntimes: { list: () => [], get: () => null },
|
||||||
|
redditAccounts: {
|
||||||
|
listRemoved: () => [{ accountId: '@t2_gone:reddit.com', redditUsername: 'gone-bot' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as WebContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps a retired account\'s conversations listed, read-only and out of the reply queue', async () => {
|
||||||
|
const app = express().use('/api', createConversationsRouter(retiredContext()));
|
||||||
|
|
||||||
|
const body = await (await request(app, '/api/conversations')).json();
|
||||||
|
|
||||||
|
expect(body).toEqual([expect.objectContaining({
|
||||||
|
roomId: 'room-alice',
|
||||||
|
readOnly: true,
|
||||||
|
needsReply: false,
|
||||||
|
hasUnreadInbound: false,
|
||||||
|
// Open, not resolved: the dashboard's default filter hides resolved
|
||||||
|
// conversations, and archived history has to stay browsable.
|
||||||
|
triageStatus: 'open',
|
||||||
|
owner: { accountId: '@t2_gone:reddit.com', redditUsername: 'gone-bot' },
|
||||||
|
})]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves the stored transcript without trying to sync more of it', async () => {
|
||||||
|
const context = retiredContext();
|
||||||
|
const app = express().use('/api', createConversationsRouter(context));
|
||||||
|
|
||||||
|
const body = await (await request(app, `/api/conversations/${encodeURIComponent(OWNED_ID)}`)).json();
|
||||||
|
|
||||||
|
expect(body).toMatchObject({ roomId: 'room-alice', readOnly: true });
|
||||||
|
expect(body.messages).toHaveLength(1);
|
||||||
|
// The fallback chat service belongs to another account: touching it would
|
||||||
|
// both sync the wrong session and mutate the wrong cache.
|
||||||
|
expect(context.services.chat!.loadConversationDetails).not.toHaveBeenCalled();
|
||||||
|
expect(context.services.chat!.saveCacheExternal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to send, suggest or mark anything in a retired conversation', async () => {
|
||||||
|
const app = express().use(express.json()).use('/api', createConversationsRouter(retiredContext()));
|
||||||
|
const path = `/api/conversations/${encodeURIComponent(OWNED_ID)}`;
|
||||||
|
|
||||||
|
const responses = await Promise.all([
|
||||||
|
request(app, `${path}/send-reply`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message: 'hello again' }),
|
||||||
|
}),
|
||||||
|
request(app, `${path}/suggest-reply`, { method: 'POST' }),
|
||||||
|
request(app, `${path}/read`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
|
||||||
|
}),
|
||||||
|
request(app, `${path}/share-media`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ assetId: '00000000-0000-4000-8000-000000000000' }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(responses.map((response) => response.status)).toEqual([409, 409, 409, 409]);
|
||||||
|
expect((await responses[0].json()).error).toMatch(/removed account/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user