| 1 | import { neon, type NeonQueryFunction } from '@neondatabase/serverless';
|
| 2 |
|
| 3 | export type HostedRecordKind =
|
| 4 | | 'agent'
|
| 5 | | 'delegation'
|
| 6 | | 'contract'
|
| 7 | | 'interaction'
|
| 8 | | 'event'
|
| 9 | | 'receipt'
|
| 10 | | 'feedback'
|
| 11 | | 'conflict'
|
| 12 | | 'profile'
|
| 13 | | 'consent'
|
| 14 | | 'revocation'
|
| 15 | | 'project'
|
| 16 | | 'agent_profile'
|
| 17 | | 'installation'
|
| 18 | | 'setup_request';
|
| 19 |
|
| 20 | export type AccountSettings = {
|
| 21 | displayName: string;
|
| 22 | contributionEnabled: boolean;
|
| 23 | retentionDays: number;
|
| 24 | evidenceSharing: 'never' | 'ask' | 'contract_only';
|
| 25 | rawConversationsStored: false;
|
| 26 | };
|
| 27 |
|
| 28 | const defaults: AccountSettings = {
|
| 29 | displayName: '',
|
| 30 | contributionEnabled: false,
|
| 31 | retentionDays: 30,
|
| 32 | evidenceSharing: 'ask',
|
| 33 | rawConversationsStored: false,
|
| 34 | };
|
| 35 |
|
| 36 | export class HostedRepository {
|
| 37 | private readonly sql: NeonQueryFunction<false, false>;
|
| 38 | private initialized?: Promise<void>;
|
| 39 |
|
| 40 | constructor(databaseUrl: string) {
|
| 41 | this.sql = neon(databaseUrl);
|
| 42 | }
|
| 43 |
|
| 44 | ensureSchema(): Promise<void> {
|
| 45 | return (this.initialized ??= (async () => {
|
| 46 | await this.sql`
|
| 47 | CREATE TABLE IF NOT EXISTS openclasp_records (
|
| 48 | operator_id TEXT NOT NULL,
|
| 49 | kind TEXT NOT NULL,
|
| 50 | record_id TEXT NOT NULL,
|
| 51 | payload JSONB NOT NULL,
|
| 52 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 53 | updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 54 | PRIMARY KEY (operator_id, kind, record_id)
|
| 55 | )
|
| 56 | `;
|
| 57 | await this.sql`
|
| 58 | CREATE INDEX IF NOT EXISTS openclasp_records_operator_created
|
| 59 | ON openclasp_records(operator_id, created_at DESC)
|
| 60 | `;
|
| 61 | await this.sql`
|
| 62 | CREATE TABLE IF NOT EXISTS openclasp_account_settings (
|
| 63 | operator_id TEXT PRIMARY KEY,
|
| 64 | display_name TEXT NOT NULL DEFAULT '',
|
| 65 | contribution_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
| 66 | retention_days INTEGER NOT NULL DEFAULT 30 CHECK (retention_days BETWEEN 0 AND 3650),
|
| 67 | evidence_sharing TEXT NOT NULL DEFAULT 'ask'
|
| 68 | CHECK (evidence_sharing IN ('never', 'ask', 'contract_only')),
|
| 69 | updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 70 | )
|
| 71 | `;
|
| 72 | })());
|
| 73 | }
|
| 74 |
|
| 75 | async upsert(
|
| 76 | operatorId: string,
|
| 77 | kind: HostedRecordKind,
|
| 78 | recordId: string,
|
| 79 | payload: unknown,
|
| 80 | ): Promise<void> {
|
| 81 | await this.ensureSchema();
|
| 82 | const encoded = JSON.stringify(payload);
|
| 83 | await this.sql`
|
| 84 | INSERT INTO openclasp_records(operator_id, kind, record_id, payload)
|
| 85 | VALUES (${operatorId}, ${kind}, ${recordId}, ${encoded}::jsonb)
|
| 86 | ON CONFLICT (operator_id, kind, record_id)
|
| 87 | DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW()
|
| 88 | `;
|
| 89 | }
|
| 90 |
|
| 91 | async list(
|
| 92 | operatorId: string,
|
| 93 | ): Promise<{ kind: HostedRecordKind; recordId: string; payload: any }[]> {
|
| 94 | await this.ensureSchema();
|
| 95 | const rows = await this.sql`
|
| 96 | SELECT kind, record_id, payload
|
| 97 | FROM openclasp_records
|
| 98 | WHERE operator_id = ${operatorId}
|
| 99 | ORDER BY created_at ASC
|
| 100 | `;
|
| 101 | return rows.map((row) => ({
|
| 102 | kind: row.kind as HostedRecordKind,
|
| 103 | recordId: String(row.record_id),
|
| 104 | payload: row.payload,
|
| 105 | }));
|
| 106 | }
|
| 107 |
|
| 108 | async dashboard(operatorId: string) {
|
| 109 | const rows = await this.list(operatorId);
|
| 110 | const ofKind = (kind: HostedRecordKind) =>
|
| 111 | rows.filter((row) => row.kind === kind).map((row) => row.payload);
|
| 112 | return {
|
| 113 | agents: [...ofKind('agent_profile'), ...ofKind('agent')],
|
| 114 | projects: ofKind('project'),
|
| 115 | installations: ofKind('installation'),
|
| 116 | setupRequests: ofKind('setup_request'),
|
| 117 | interactions: ofKind('interaction'),
|
| 118 | events: ofKind('event'),
|
| 119 | conflicts: ofKind('conflict'),
|
| 120 | receipts: ofKind('receipt'),
|
| 121 | profiles: ofKind('profile'),
|
| 122 | };
|
| 123 | }
|
| 124 |
|
| 125 | async getSettings(operatorId: string): Promise<AccountSettings> {
|
| 126 | await this.ensureSchema();
|
| 127 | const rows = await this.sql`
|
| 128 | SELECT display_name, contribution_enabled, retention_days, evidence_sharing
|
| 129 | FROM openclasp_account_settings
|
| 130 | WHERE operator_id = ${operatorId}
|
| 131 | `;
|
| 132 | const row = rows[0] as
|
| 133 | | {
|
| 134 | display_name: string;
|
| 135 | contribution_enabled: boolean;
|
| 136 | retention_days: number;
|
| 137 | evidence_sharing: AccountSettings['evidenceSharing'];
|
| 138 | }
|
| 139 | | undefined;
|
| 140 | return row
|
| 141 | ? {
|
| 142 | displayName: row.display_name,
|
| 143 | contributionEnabled: row.contribution_enabled,
|
| 144 | retentionDays: row.retention_days,
|
| 145 | evidenceSharing: row.evidence_sharing,
|
| 146 | rawConversationsStored: false,
|
| 147 | }
|
| 148 | : defaults;
|
| 149 | }
|
| 150 |
|
| 151 | async saveSettings(
|
| 152 | operatorId: string,
|
| 153 | settings: Omit<AccountSettings, 'rawConversationsStored'>,
|
| 154 | ): Promise<AccountSettings> {
|
| 155 | await this.ensureSchema();
|
| 156 | await this.sql`
|
| 157 | INSERT INTO openclasp_account_settings(
|
| 158 | operator_id, display_name, contribution_enabled, retention_days, evidence_sharing
|
| 159 | ) VALUES (
|
| 160 | ${operatorId}, ${settings.displayName}, ${settings.contributionEnabled},
|
| 161 | ${settings.retentionDays}, ${settings.evidenceSharing}
|
| 162 | )
|
| 163 | ON CONFLICT (operator_id) DO UPDATE SET
|
| 164 | display_name = EXCLUDED.display_name,
|
| 165 | contribution_enabled = EXCLUDED.contribution_enabled,
|
| 166 | retention_days = EXCLUDED.retention_days,
|
| 167 | evidence_sharing = EXCLUDED.evidence_sharing,
|
| 168 | updated_at = NOW()
|
| 169 | `;
|
| 170 | return { ...settings, rawConversationsStored: false };
|
| 171 | }
|
| 172 | }
|