"). * Guests have no account identity; they can export their transcript and delete * their own session through the chat API. * * Local deletion only. Aiwend deletes what it stores on this site. When an assistant * uses an external AI connection, the visitor's messages and the knowledge excerpts * sent with them reach that provider, and erasure here cannot remove the provider's * copies: the result and the privacy-policy text say so, and the provider's own * retention terms apply. The local simulated demo sends nothing anywhere. */ final class PrivacyService { public const PAGE = 50; public function __construct(private Connection $db, private SessionService $sessions) { } /** * @return array{items: list}>, done:bool} */ public function export(int $siteId, int $userId, int $page = 1): array { $items = []; $offset = max(0, $page - 1) * self::PAGE; $sessions = $this->db->select( 'SELECT s.id, s.created_at, b.name AS bot FROM {chat_sessions} s LEFT JOIN {bots} b ON b.id = s.bot_id AND b.site_id = s.site_id AND b.status <> \'deleted\' WHERE s.site_id = ? AND s.user_id = ? ORDER BY s.id LIMIT ? OFFSET ?', [$siteId, $userId, self::PAGE, $offset] ); foreach ($sessions as $s) { $sid = (int) $s['id']; $items[] = ['group' => 'aiwend-conversations', 'id' => "aiwend-session-$sid", 'data' => [ 'Conversation' => (string) $sid, 'Assistant' => (string) ($s['bot'] ?? 'Deleted assistant'), 'Started' => gmdate('c', (int) $s['created_at']), ]]; foreach ($this->db->select('SELECT id, role, content, created_at FROM {messages} WHERE site_id = ? AND session_id = ? ORDER BY seq', [$siteId, $sid]) as $m) { $cites = $this->db->select('SELECT n, title, url FROM {message_citations} WHERE site_id = ? AND message_id = ? ORDER BY n', [$siteId, (int) $m['id']]); $items[] = ['group' => 'aiwend-messages', 'id' => 'aiwend-message-' . (int) $m['id'], 'data' => [ 'Conversation' => (string) $sid, 'From' => $m['role'] === 'user' ? 'You' : 'Assistant', 'Message' => (string) $m['content'], 'Sent' => gmdate('c', (int) $m['created_at']), 'Feedback' => (string) ($this->db->selectOne('SELECT rating FROM {message_feedback} WHERE site_id = ? AND message_id = ?', [$siteId, (int) $m['id']])['rating'] ?? ''), 'Sources cited' => implode('; ', array_map(static fn($c) => '[' . $c['n'] . '] ' . $c['title'] . ($c['url'] ? ' ' . $c['url'] : ''), $cites)), ]]; } } $done = count($sessions) < self::PAGE; if ($done) { foreach ($this->db->select("SELECT id, title, origin, visibility, created_at FROM {knowledge_sources} WHERE site_id = ? AND owner_user_id = ? AND status <> 'deleted' ORDER BY id", [$siteId, $userId]) as $k) { $items[] = ['group' => 'aiwend-knowledge', 'id' => 'aiwend-source-' . (int) $k['id'], 'data' => [ 'Knowledge source' => (string) $k['title'], 'Type' => (string) $k['origin'], 'Visibility' => (string) $k['visibility'], 'Added' => gmdate('c', (int) $k['created_at']), ]]; } } return ['items' => $items, 'done' => $done]; } /** * @return array{removed:int, retained:int, messages:list, done:bool} * message codes: knowledge_retained (count), local_only, usage_retained */ public function erase(int $siteId, int $userId, int $page = 1): array { $removed = 0; $ids = array_map(static fn($r) => (int) $r['id'], $this->db->select( 'SELECT id FROM {chat_sessions} WHERE site_id = ? AND user_id = ? ORDER BY id LIMIT ?', [$siteId, $userId, self::PAGE] )); foreach ($ids as $sid) { // Revoke first: an in-flight request cannot authenticate further work, and a request // already generating discards its answer when it finds the session gone. $this->sessions->revoke($siteId, $sid); $removed += 1 + (int) ($this->db->selectOne('SELECT COUNT(*) AS n FROM {messages} WHERE site_id = ? AND session_id = ?', [$siteId, $sid])['n'] ?? 0); $this->sessions->delete($siteId, $sid); } $done = count($ids) < self::PAGE; $messages = []; $retained = 0; if ($done) { $removed += $this->db->execute("UPDATE {audit} SET actor = 'erased-user' WHERE site_id = ? AND actor = ?", [$siteId, 'user:' . $userId]); $owned = $this->db->execute('UPDATE {knowledge_sources} SET owner_user_id = NULL WHERE site_id = ? AND owner_user_id = ?', [$siteId, $userId]); if ($owned > 0) { $retained += $owned; $messages[] = ['code' => 'knowledge_retained', 'count' => $owned]; } $messages[] = ['code' => 'local_only', 'count' => 0]; $messages[] = ['code' => 'usage_retained', 'count' => 0]; } return ['removed' => $removed, 'retained' => $retained, 'messages' => $messages, 'done' => $done]; } }