import { marked } from "marked"; import { getPool } from "../../db/index.js"; import { sortClause, eventSearchClause } from "./query.js"; // Every query is scoped to user_id; the only interpolated SQL is the // whitelisted sort clause. Agreements are deliberately NOT re-scoped by // user_id — an event may reference a public (user_id IS NULL) agreement. export async function listCore(userId, { from, to, q = "", sort, dir, limit = 50, offset = 0 } = {}) { const client = await getPool(); try { const params = [userId]; let where = `WHERE e.user_id = $1`; if (from && to) { params.push(from, to); // Half-open: includes the whole `to` day. `<= to::date` would drop // everything after midnight of that day. where += ` AND e.created_as_ts >= $${params.length - 1}::date AND e.created_as_ts < ($${params.length}::date + 1)`; } if (q) { params.push(q); where += ` AND ${eventSearchClause(params.length)}`; } const total = ( await client.query( `SELECT COUNT(*)::int AS total FROM event e INNER JOIN agreement agr ON e.agreement_id = agr.id ${where}`, params, ) ).rows[0].total; const rows = ( await client.query( `SELECT e.event_id_uuid AS "eventUuid", COALESCE(es.short_name, e.sender_id) AS sender, COALESCE(er.short_name, e.recipient_id) AS receiver, e.created_as_ts AS date, agr.agreement_id_uuid AS "agreementUuid", ac.title AS "agreementTitle", octet_length(ed.data::text) AS "size" FROM event e INNER JOIN agreement agr ON e.agreement_id = agr.id LEFT JOIN agreement_content ac ON ac.agreement_uuid = agr.agreement_id_uuid LEFT JOIN event_data ed ON ed.event_id = e.id LEFT JOIN entity es ON es.did_id = e.sender_id AND es.user_id = $1 LEFT JOIN entity er ON er.did_id = e.recipient_id AND er.user_id = $1 ${where} ${sortClause(sort, dir)} LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, [...params, limit, offset], ) ).rows; return { rows, total }; } finally { await client.release(); } } // One row per AUDIT RECORD, not per event: `eventUuid` repeats across rows and // `auditId` is what identifies a row uniquely. export async function listAudit(userId, { from, to, q = "", sort, dir, limit = 50, offset = 0 } = {}) { const client = await getPool(); try { const params = [userId]; let where = `WHERE a.event_id IS NOT NULL`; if (from && to) { params.push(from, to); where += ` AND a.created_ts >= $${params.length - 1}::date AND a.created_ts < ($${params.length}::date + 1)`; } if (q) { params.push(q); where += ` AND ${eventSearchClause(params.length)}`; } const baseFrom = ` FROM audit a INNER JOIN event e ON e.event_id_uuid = a.event_id AND e.user_id = $1 INNER JOIN agreement agr ON agr.id = e.agreement_id`; const total = ( await client.query(`SELECT COUNT(*)::int AS total ${baseFrom} ${where}`, params) ).rows[0].total; const rows = ( await client.query( `SELECT a.audit_id AS "auditId", a.digest AS "auditDigest", e.event_id_uuid AS "eventUuid", COALESCE(es.short_name, e.sender_id) AS sender, COALESCE(er.short_name, e.recipient_id) AS receiver, a.created_ts AS date, agr.agreement_id_uuid AS "agreementUuid", ac.title AS "agreementTitle", ( octet_length(a.digest) + COALESCE((SELECT SUM(octet_length(s.jws)) FROM audit_signature s WHERE s.audit_id = a.audit_id), 0) ) AS "size" ${baseFrom} LEFT JOIN agreement_content ac ON ac.agreement_uuid = agr.agreement_id_uuid LEFT JOIN entity es ON es.did_id = e.sender_id AND es.user_id = $1 LEFT JOIN entity er ON er.did_id = e.recipient_id AND er.user_id = $1 ${where} ${sortClause(sort, dir)} LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, [...params, limit, offset], ) ).rows; return { rows, total }; } finally { await client.release(); } } export async function getEvent(userId, eventUuid) { const client = await getPool(); try { const record = ( await client.query( `SELECT JSON_BUILD_OBJECT( 'version', e.version, 'eventId', e.event_id_uuid, 'type', et.value, 'senderId', e.sender_id, 'recipientId', e.recipient_id, 'created', e.created, 'agreementId', agr.agreement_id_uuid, 'data', ed.data ) AS record FROM event e JOIN event_type et ON et.id = e.event_type_id JOIN agreement agr ON agr.id = e.agreement_id LEFT JOIN event_data ed ON ed.event_id = e.id WHERE e.user_id = $1 AND e.event_id_uuid = $2`, [userId, eventUuid], ) ).rows[0]?.record ?? null; if (record?.data) { try { record.data = JSON.parse(record.data); } catch { // leave the raw string if it isn't JSON } } return record; } finally { await client.release(); } } // Cached agreement markdown rendered to HTML for the "click the agreement" modal. // Scoped to the viewer: their own agreement or a public (user_id IS NULL) one. export async function getAgreement(userId, agreementUuid) { const client = await getPool(); try { const row = ( await client.query( `SELECT title, markdown FROM agreement_content WHERE agreement_uuid = $1 AND (user_id = $2 OR user_id IS NULL) LIMIT 1`, [agreementUuid, userId], ) ).rows[0]; if (!row) return null; return { title: row.title, html: marked(row.markdown || "") }; } finally { await client.release(); } } export async function getEventDetails(userId, eventUuid) { const client = await getPool(); try { const event = await getEvent(userId, eventUuid); if (!event) return null; const result = await client.query(` SELECT s.signer_id, r.value AS role FROM signature s JOIN agreement agr ON agr.id = s.agreement_id LEFT JOIN role r ON r.id = s.role_id WHERE agr.agreement_id_uuid = $1 `, [event.agreementId]) const roleByDid = {}; for (const row of result.rows) { roleByDid[row.signer_id] = row.role; } const roles = { sender: roleByDid[event.senderId] || null, receiver: roleByDid[event.recipientId] || null } // Resolve the sender/receiver DIDs to their entity short names (user@fedid…). const nameResult = await client.query(` SELECT did_id, short_name FROM entity WHERE user_id = $1 AND did_id = ANY($2::text[]) `, [userId, [event.senderId, event.recipientId]]) const nameByDid = {} for (const row of nameResult.rows) nameByDid[row.did_id] = row.short_name const names = { sender: nameByDid[event.senderId] || null, receiver: nameByDid[event.recipientId] || null } const permittedResult = await client.query(` SELECT p.value FROM purpose p INNER JOIN agreement_purpose ap ON p.id = ap.purpose_id INNER JOIN agreement a ON ap.agreement_id = a.id WHERE a.agreement_id_uuid = $1 `, [event.agreementId]) const permitted = permittedResult.rows.map(row => row.value); const prohibitedResult = await client.query(` SELECT p.value FROM prohibition p INNER JOIN agreement_prohibition aph ON p.id = aph.prohibition_id INNER JOIN agreement a ON aph.agreement_id = a.id WHERE a.agreement_id_uuid = $1 `, [event.agreementId]) const prohibited = prohibitedResult.rows.map(row => row.value) const title = (await client.query(` SELECT title FROM agreement_content WHERE agreement_uuid = $1 `, [event.agreementId])).rows[0]?.title ?? null; return { event, roles, names, agreement: {permitted, prohibited, title} } } finally { client.release(); } }