diff --git a/.env.example b/.env.example index 61561eb9..37d2f0ce 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,10 @@ ENCRYPTION_KEY="ZGV2LW9ubHktZW5jcnlwdGlvbi1rZXktMzItYnl0ZXM=" # PLAID_SECRET="" # PLAID_ENV="sandbox" +# An OpenRouter key, for the model the home page picks and fills in its widgets with. Without it the +# home page says it is not set up. +# OPENROUTER_KEY="" + # Who can create an account: addresses and domains, comma-separated. Unset, anyone can. # ALLOWED_SIGNUP_EMAILS="" diff --git a/bun.lock b/bun.lock index 7630e4f4..7189a6a3 100644 --- a/bun.lock +++ b/bun.lock @@ -167,6 +167,7 @@ "@proxy/ui": "workspace:*", "@proxy/utils": "workspace:*", "@tailwindcss/vite": "^4.3.3", + "chrono-node": "^2.10.3", "lucide-react": "^1.43.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -767,6 +768,8 @@ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "chrono-node": ["chrono-node@2.10.3", "", {}, "sha512-MOfrzXgSMZTH4SZ5gyRKQCVwiCrCKY+uYkSJiWoRLXS88DA6WGJm8UjgS+VVEuUQeew/UUXaxrO+SeDn/WCisg=="], + "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], diff --git a/docker-compose.yml b/docker-compose.yml index b0b4f7db..825e439b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,7 @@ services: PLAID_ENV: ${PLAID_ENV:-} RESEND_KEY: ${RESEND_KEY:-} EMAIL_FROM: ${EMAIL_FROM:-} + OPENROUTER_KEY: ${OPENROUTER_KEY:-} TELEMETRY_ENABLED: ${TELEMETRY_ENABLED:-} logging: *logging diff --git a/packages/api/src/activity/route.test.ts b/packages/api/src/activity/route.test.ts new file mode 100644 index 00000000..41f17828 --- /dev/null +++ b/packages/api/src/activity/route.test.ts @@ -0,0 +1,67 @@ +import {afterEach, beforeEach, describe, expect, mock, setSystemTime, test} from 'bun:test'; +import {Ok} from 'ts-results-es'; + +const getUserOrgId = mock(); +const listAuditEntries = mock(); +const countAuditEntriesByDay = mock(); +const countAuditEntriesByAction = mock(); + +mock.module('@proxy/db/organization', () => ({getUserOrgId})); +mock.module('@proxy/db/audit', () => ({listAuditEntries, countAuditEntriesByDay, countAuditEntriesByAction})); + +function makeRequest(query: Record) { + return {user: {userId: 'user-1', email: 'test@example.com', name: 'Test User'}, query}; +} + +beforeEach(() => { + mock.clearAllMocks(); + getUserOrgId.mockResolvedValue(Ok('org-1')); + listAuditEntries.mockResolvedValue(Ok([])); + countAuditEntriesByDay.mockResolvedValue(Ok([{day: '2026-10-16', agentId: null, count: 2, denied: 0}])); + countAuditEntriesByAction.mockResolvedValue(Ok([{connectionId: 'c1', collectionId: 'events', action: 'create', outcome: 'allowed', count: 2}])); + // Late on the 15th in UTC: already the 16th in Berlin. + setSystemTime(new Date('2026-10-15T23:30:00Z')); +}); + +afterEach(() => { + setSystemTime(); +}); + +describe('handleActivitySummaryRoute', () => { + test('counts a year of whole weeks and the past week, by days on the person’s clock', async () => { + const {handleActivitySummaryRoute} = await import('./route'); + const result = await handleActivitySummaryRoute(makeRequest({timeZone: 'Europe/Berlin'}) as never); + + expect(result.unwrap()).toEqual({ + today: '2026-10-16', + days: [{day: '2026-10-16', agentId: null, count: 2, denied: 0}], + week: [{connectionId: 'c1', collectionId: 'events', action: 'create', outcome: 'allowed', count: 2}], + }); + expect(countAuditEntriesByDay).toHaveBeenCalledWith('org-1', 'Europe/Berlin', '2025-10-11'); + expect(countAuditEntriesByAction).toHaveBeenCalledWith('org-1', 'Europe/Berlin', '2026-10-10'); + }); + + test('refuses what isn’t a time zone before it reaches the database', async () => { + const {handleActivitySummaryRoute} = await import('./route'); + const result = await handleActivitySummaryRoute(makeRequest({timeZone: "UTC'; --"}) as never); + + expect(result.unwrapErr().kind).toBe('parse_error'); + expect(countAuditEntriesByDay).not.toHaveBeenCalled(); + }); +}); + +describe('handleListActivityRoute', () => { + test('narrows to one outcome and a span of time', async () => { + const {handleListActivityRoute} = await import('./route'); + await handleListActivityRoute(makeRequest({outcome: 'denied', from: '2026-10-14T00:00:00+02:00', to: '2026-10-15T00:00:00+02:00'}) as never); + + expect(listAuditEntries).toHaveBeenCalledWith('org-1', {outcome: 'denied', from: new Date('2026-10-13T22:00:00Z'), to: new Date('2026-10-14T22:00:00Z')}, 500); + }); + + test('what Personal Agent Proxy did itself is the entries without an agent', async () => { + const {handleListActivityRoute} = await import('./route'); + await handleListActivityRoute(makeRequest({agentId: 'proxy'}) as never); + + expect(listAuditEntries).toHaveBeenCalledWith('org-1', {agentId: null}, 500); + }); +}); diff --git a/packages/api/src/activity/route.ts b/packages/api/src/activity/route.ts index 03cad85d..f6fe5d89 100644 --- a/packages/api/src/activity/route.ts +++ b/packages/api/src/activity/route.ts @@ -2,7 +2,8 @@ import type {Result} from 'ts-results-es'; import {z} from 'zod'; import {type ApiError, Do, parseSchema} from '@proxy/utils'; -import {listAuditEntries} from '@proxy/db/audit'; +import {addDays, dayIn, isTimeZone} from '@proxy/utils/days'; +import {type AuditActionCount, type AuditDayCount, countAuditEntriesByAction, countAuditEntriesByDay, listAuditEntries} from '@proxy/db/audit'; import type {AuthenticatedRequest} from '../server/middleware/require-auth'; import {requireUserOrgId} from '../utils/user-org'; @@ -12,7 +13,8 @@ const MAX_ENTRIES = 500; export type AuditEntryResponse = { id: string; at: string; - agentId: string; + // None for what Personal Agent Proxy did itself, as the home page's widgets write. + agentId: string | null; connectionId: string; collectionId: string; // list, view, create, update or delete, or a command of the collection, such as `send`. @@ -24,14 +26,59 @@ export type AuditEntryResponse = { via: 'web' | 'mcp'; }; -const querySchema = z.object({agentId: z.string().min(1).optional(), connectionId: z.string().min(1).optional()}); +// `?agentId=proxy` is what Personal Agent Proxy did itself. +const PROXY = 'proxy'; + +const querySchema = z.object({ + agentId: z.string().min(1).optional(), + connectionId: z.string().min(1).optional(), + outcome: z.enum(['allowed', 'denied']).optional(), + // Only what was made from `from` until before `to`, as a day on the person's clock is. + from: z.iso.datetime({offset: true}).optional(), + to: z.iso.datetime({offset: true}).optional(), +}); /** The organization's agent requests, newest first, the latest 500. */ export function handleListActivityRoute(request: AuthenticatedRequest): Promise> { return Do(async ($) => { - const filter = $(parseSchema(querySchema, request.query)); + const {agentId, connectionId, outcome, from, to} = $(parseSchema(querySchema, request.query)); const orgId = $(await requireUserOrgId(request)); + const filter = { + ...(agentId === undefined ? {} : {agentId: agentId === PROXY ? null : agentId}), + ...(connectionId === undefined ? {} : {connectionId}), + ...(outcome === undefined ? {} : {outcome}), + ...(from === undefined ? {} : {from: new Date(from)}), + ...(to === undefined ? {} : {to: new Date(to)}), + }; const rows = $(await listAuditEntries(orgId, filter, MAX_ENTRIES)); return {entries: rows.map(({createdAt, ...entry}) => ({...entry, at: createdAt.toISOString()}))}; }); } + +// The home page's graph: 53 weeks, so a year of whole weeks ends in this one whatever day it is. +const GRAPH_DAYS = 53 * 7; +// Its summary line: today and the six days before it. +const WEEK_DAYS = 7; + +const summaryQuerySchema = z.object({timeZone: z.string().refine(isTimeZone, 'Not a time zone')}); + +export type ActivitySummaryResponse = { + // Today on the person's clock, `2026-10-16`, the day the graph ends on. + today: string; + // Each agent's requests each day of the graph's, on the person's clock, days without any left + // out; an `agentId` of null is what Personal Agent Proxy did itself. + days: AuditDayCount[]; + // The past week's requests by connection, collection, action and outcome, the most first. + week: AuditActionCount[]; +}; + +/** The organization's activity by day for the home page's graph, and the past week's by action. */ +export function handleActivitySummaryRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const {timeZone} = $(parseSchema(summaryQuerySchema, request.query)); + const orgId = $(await requireUserOrgId(request)); + const today = dayIn(new Date(), timeZone); + const [days, week] = await Promise.all([countAuditEntriesByDay(orgId, timeZone, addDays(today, 1 - GRAPH_DAYS)), countAuditEntriesByAction(orgId, timeZone, addDays(today, 1 - WEEK_DAYS))]); + return {today, days: $(days), week: $(week)}; + }); +} diff --git a/packages/api/src/connections/connect-flow.ts b/packages/api/src/connections/connect-flow.ts index a678eee8..9a4190a6 100644 --- a/packages/api/src/connections/connect-flow.ts +++ b/packages/api/src/connections/connect-flow.ts @@ -28,14 +28,15 @@ import {requireUserOrgId} from '../utils/user-org'; * signing in again works, and ends on the connection's page, or back on the catalog with * `?error=` (with `?error=-unconfigured` when the server lacks what the * sign-in needs, such as Google's client); a sign-in begun from the welcome flow (`returnTo: 'welcome'`) ends on its - * page for the connection, or its catalog, instead. What the provider's sign-in page is and how a + * page for the connection, or its catalog, instead, and one begun from a home page widget's card + * (`returnTo: 'home'`) back on the home page. What the provider's sign-in page is and how a * code becomes tokens and a connection is the integration's. */ const stateCookieMaxAgeMs = 15 * 60 * 1000; -/** Where a sign-in ends when not on the connection's page: the welcome flow it began from. */ -export const returnToSchema = z.enum(['welcome']); +/** Where a sign-in ends when not on the connection's page: the welcome flow or the home page it began from. */ +export const returnToSchema = z.enum(['welcome', 'home']); type ReturnTo = z.infer; @@ -211,6 +212,10 @@ export function signInFlow(args: SignInArgs) { res.redirect(`${appUrl}/welcome/connections/${id}`); return; } + if (returnTo === 'home') { + res.redirect(`${appUrl}/`); + return; + } res.redirect(`${appUrl}/connections/${id}${added ? '?added' : ''}`); } diff --git a/packages/api/src/connections/google/calendar-api.ts b/packages/api/src/connections/google/calendar-api.ts index 74e8b783..86a4316c 100644 --- a/packages/api/src/connections/google/calendar-api.ts +++ b/packages/api/src/connections/google/calendar-api.ts @@ -24,6 +24,11 @@ const calendarSchema = z.object({ description: z.string().optional(), primary: z.boolean().optional(), timeZone: z.string().optional(), + // Whether Google Calendar shows it, as ticked in its list, and what the account may do in it: + // `owner` or `writer` make events, `reader` sees them, `freeBusyReader` only when it is busy. + selected: z.boolean().optional(), + accessRole: z.string().optional(), + backgroundColor: z.string().optional(), }); export type GoogleCalendar = z.infer; @@ -106,16 +111,20 @@ export function getCalendar(accessToken: string, calendarId: string): Promise> { +export type EventsQuery = {from: string; to?: string; search: string | null; page: string | null}; + +/** + * A page of a calendar's events ending after `from`, and starting before `to` where it is given, + * soonest first, matching `search` where there is one. + */ +export async function listEvents(accessToken: string, calendarId: string, args: EventsQuery): Promise> { const url = calendarUrl(calendarId, '/events'); url.searchParams.set('singleEvents', 'true'); url.searchParams.set('orderBy', 'startTime'); url.searchParams.set('timeMin', args.from); + if (args.to) { + url.searchParams.set('timeMax', args.to); + } url.searchParams.set('maxResults', String(EVENTS_PAGE_SIZE)); if (args.search) { url.searchParams.set('q', args.search); diff --git a/packages/api/src/connections/google/gmail-api.test.ts b/packages/api/src/connections/google/gmail-api.test.ts index d91aab11..9f3e1c89 100644 --- a/packages/api/src/connections/google/gmail-api.test.ts +++ b/packages/api/src/connections/google/gmail-api.test.ts @@ -9,7 +9,7 @@ mock.module('@proxy/db/connection', () => ({updateConnectionCredential: mock(), mock.module('../../utils/env', () => ({env: {NODE_ENV: 'production', APP_URL: 'https://app.example.com', GOOGLE_CLIENT_ID: 'client-1', GOOGLE_CLIENT_SECRET: 'secret-1'}})); -const {attachmentNames, folderOf, folderQuery, htmlToText, mapWithLimit, messageBody} = await import('./gmail-api'); +const {attachmentNames, folderOf, folderQuery, htmlToText, messageBody} = await import('./gmail-api'); const base64url = (text: string, encoding: BufferEncoding = 'utf8'): string => Buffer.from(text, encoding).toString('base64url'); @@ -131,18 +131,3 @@ test('htmlToText takes time in proportion to the email, however many tags are le expect(performance.now() - started).toBeLessThan(1000); }); - -test('mapWithLimit keeps the order and never runs more than the limit at once', async () => { - let running = 0; - let most = 0; - const results = await mapWithLimit([5, 1, 4, 2, 3, 0, 6], 3, async (value) => { - running += 1; - most = Math.max(most, running); - await Bun.sleep(value); - running -= 1; - return value * 10; - }); - - expect(results).toEqual([50, 10, 40, 20, 30, 0, 60]); - expect(most).toBe(3); -}); diff --git a/packages/api/src/connections/google/gmail-api.ts b/packages/api/src/connections/google/gmail-api.ts index a6521781..9d455168 100644 --- a/packages/api/src/connections/google/gmail-api.ts +++ b/packages/api/src/connections/google/gmail-api.ts @@ -1,7 +1,7 @@ import {Ok, Result} from 'ts-results-es'; import {z} from 'zod'; -import type {ApiError} from '@proxy/utils'; +import {type ApiError, mapWithLimit} from '@proxy/utils'; import {googleDelete, googleRequest, googleWrite} from './google-client'; @@ -25,8 +25,8 @@ const DRAFTS_PAGE_SIZE = 500; /** Gmail's message and draft ids, as every id an agent sends is checked against before it reaches an address. */ export const GMAIL_ID = /^[A-Za-z0-9_-]+$/; -/** The headers a list reads of each message. */ -const LIST_HEADERS = ['Subject', 'From', 'To']; +/** The headers a list reads of each message, and Cc, which the home page's widgets read. */ +const LIST_HEADERS = ['Subject', 'From', 'To', 'Cc']; const headerSchema = z.object({name: z.string(), value: z.string()}); @@ -314,23 +314,9 @@ export function messageBody(payload: MessagePart | undefined): string { // ---- Calls ----------------------------------------------------------------------------------- -/** Runs `work` on every item, at most `limit` at once, the results in the items' order. */ -export async function mapWithLimit(items: T[], limit: number, work: (item: T) => Promise): Promise { - const results = new Array(items.length); - // One iterator shared by every worker, so each item is taken once. - const queue = items.entries(); - const worker = async (): Promise => { - for (const [index, item] of queue) { - results[index] = await work(item); - } - }; - await Promise.all(Array.from({length: Math.min(limit, items.length)}, worker)); - return results; -} - -/** One page of message ids, newest first, in a folder and matching a search, from the page a previous one pointed at. */ -export async function listMessageIds(accessToken: string, query: FolderQuery, pageToken: string | null): Promise, ApiError>> { - const params = {maxResults: String(PAGE_SIZE), labelIds: query.labelIds, q: query.q, includeSpamTrash: query.includeSpamTrash ? 'true' : null, pageToken}; +/** One page of message ids, newest first, in a folder and matching a search, from the page a previous one pointed at, of 50 unless asked for fewer. */ +export async function listMessageIds(accessToken: string, query: FolderQuery, pageToken: string | null, pageSize = PAGE_SIZE): Promise, ApiError>> { + const params = {maxResults: String(pageSize), labelIds: query.labelIds, q: query.q, includeSpamTrash: query.includeSpamTrash ? 'true' : null, pageToken}; const list = await googleRequest(accessToken, gmailUrl('/messages', params), messageListSchema); return list.map(({messages = [], nextPageToken}) => ({items: messages.map(({id}) => id), nextPage: nextPageToken ?? null})); } diff --git a/packages/api/src/intent/classify.test.ts b/packages/api/src/intent/classify.test.ts new file mode 100644 index 00000000..e3e6a282 --- /dev/null +++ b/packages/api/src/intent/classify.test.ts @@ -0,0 +1,176 @@ +import {beforeEach, describe, expect, mock, spyOn, test} from 'bun:test'; + +const mockEnv: {OPENROUTER_KEY?: string} = {}; +mock.module('../utils/env', () => ({env: mockEnv})); + +const text = 'Create a calendar invite with Jon Burke and Jacky Doe at One Boulevard Plaza at 7pm tomorrow'; + +// What OpenRouter answers: the model's JSON as the message's text. +let content = ''; +let status = 200; +const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((async () => + status === 200 ? Response.json({choices: [{message: {content}}], provider: 'Cerebras'}) : Response.json({error: {message: 'down'}}, {status})) as unknown as typeof fetch); + +function answer(values: Record) { + content = JSON.stringify(values); +} + +// The body of the request made to OpenRouter. +function sent(): { + model: string; + messages: Array<{role: string; content: string}>; + provider: {order: string[]}; + response_format: {json_schema: {schema: {required: string[]; properties: {widget: {enum: string[]}}}}}; +} { + return JSON.parse(String(fetchSpy.mock.calls.at(-1)?.[1]?.body)); +} + +beforeEach(() => { + fetchSpy.mockClear(); + mockEnv.OPENROUTER_KEY = 'sk-or-1'; + status = 200; + answer({ + widget: 'new_event', + attendees: ['Jon Burke', ' Jacky Doe ', 'Jon Burke'], + location: 'One Boulevard Plaza', + title: null, + person: null, + to: [], + subject: null, + from: null, + about: null, + name: null, + owner: null, + }); +}); + +describe('classifyIntent', () => { + test('asks gpt-oss-120b, on Cerebras first, for the widget and every field in one answer', async () => { + const {classifyIntent} = await import('./classify'); + await classifyIntent(text); + + const body = sent(); + expect(body.model).toBe('openai/gpt-oss-120b'); + expect(body.provider.order).toEqual(['Cerebras', 'Groq']); + expect(body.messages[1]).toEqual({role: 'user', content: text}); + expect(body.messages[0]?.content).toContain('- find_file: Find or open a file'); + const schema = body.response_format.json_schema.schema; + expect(schema.properties.widget.enum).toEqual([ + 'new_event', + 'change_event', + 'show_calendar', + 'find_person', + 'write_email', + 'find_email', + 'find_file', + 'calculate', + 'convert_units', + 'incomplete', + 'none', + ]); + expect(schema.required).toEqual([ + 'widget', + 'attendees', + 'location', + 'title', + 'changeWith', + 'changeEvent', + 'changeFrom', + 'changeTo', + 'invite', + 'uninvite', + 'newTitle', + 'newLocation', + 'meetingWith', + 'eventName', + 'person', + 'to', + 'subject', + 'body', + 'from', + 'about', + 'name', + 'owner', + 'expression', + 'amount', + 'fromUnit', + 'toUnit', + ]); + }); + + test("answers the picked widget's fields alone, each value trimmed and once", async () => { + const {classifyIntent} = await import('./classify'); + const intent = (await classifyIntent(text)).unwrap(); + + expect(intent.widget).toBe('new_event'); + expect(intent.fields).toEqual([ + {id: 'attendees', name: 'Attendees', values: ['Jon Burke', 'Jacky Doe']}, + {id: 'location', name: 'Location', values: ['One Boulevard Plaza']}, + {id: 'title', name: 'Title', values: []}, + ]); + }); + + test('answers search terms as each one\'s words joined by " | "', async () => { + answer({widget: 'find_email', from: null, about: [['flight', ' itinerary ', 'e|ticket'], ['SFO'], []]}); + const {classifyIntent} = await import('./classify'); + const intent = (await classifyIntent('find the email about my flight to sfo')).unwrap(); + + expect(intent.fields).toEqual([ + {id: 'from', name: 'From', values: []}, + {id: 'about', name: 'About', values: ['flight | itinerary | e ticket', 'SFO']}, + ]); + const schema = sent().response_format.json_schema.schema as unknown as {properties: Record}; + expect(schema.properties.about).toEqual({type: 'array', items: {type: 'array', items: {type: 'string'}}}); + }); + + test("every field id is one widget's alone, as the model answers them all in one object", async () => { + const {WIDGETS} = await import('./widgets'); + const ids = WIDGETS.flatMap((widget) => widget.fields.map((field) => field.id)); + expect(new Set(ids).size).toBe(ids.length); + }); + + test('holds a unit to the units there are', async () => { + const {classifyIntent} = await import('./classify'); + await classifyIntent('5 miles in km'); + + const schema = sent().response_format.json_schema.schema as unknown as {properties: Record}>}; + expect(schema.properties.fromUnit?.enum).toContain('mi'); + expect(schema.properties.toUnit?.enum).toContain(null); + }); + + test('none, or a widget there is no such of, has no fields', async () => { + const {classifyIntent} = await import('./classify'); + for (const widget of ['none', 'make_coffee']) { + answer({widget}); + expect((await classifyIntent('hi')).unwrap()).toMatchObject({widget: 'none', fields: []}); + } + }); + + test('tells text still being typed from a whole request no widget does', async () => { + const {classifyIntent} = await import('./classify'); + answer({widget: 'incomplete'}); + + expect((await classifyIntent('find the')).unwrap()).toMatchObject({widget: 'incomplete', fields: []}); + expect(sent().messages[0]?.content).toContain('- incomplete: the text is cut off'); + }); + + test('is unavailable without a key, and asks nothing', async () => { + delete mockEnv.OPENROUTER_KEY; + const {classifyIntent} = await import('./classify'); + + expect((await classifyIntent(text)).unwrapErr()).toMatchObject({kind: 'unavailable', message: 'The home page is not set up: set `OPENROUTER_KEY`.'}); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test('OpenRouter failing, or the model answering outside the form, is a provider out of reach', async () => { + const {classifyIntent} = await import('./classify'); + + status = 503; + expect((await classifyIntent(text)).unwrapErr().kind).toBe('provider_unreachable'); + status = 200; + content = 'Sure! Here is the form:'; + expect((await classifyIntent(text)).unwrapErr().kind).toBe('provider_unreachable'); + answer({attendees: ['Jon']}); + expect((await classifyIntent(text)).unwrapErr().kind).toBe('provider_unreachable'); + }); +}); diff --git a/packages/api/src/intent/classify.ts b/packages/api/src/intent/classify.ts new file mode 100644 index 00000000..df506d7b --- /dev/null +++ b/packages/api/src/intent/classify.ts @@ -0,0 +1,144 @@ +import {Err, Ok, Result} from 'ts-results-es'; +import {z} from 'zod'; + +import {ApiErr, type ApiError, httpRequest, parseSchema} from '@proxy/utils'; + +import {env} from '../utils/env'; +import {INCOMPLETE, NO_WIDGET, WIDGETS, type WidgetField} from './widgets'; + +/** + * What a person typed into the home page's input, in one request to gpt-oss-120b over OpenRouter, + * on Cerebras, the fastest host, else Groq: the widget that fits, and its fields filled in from the + * text, in about 150 ms. Picked over Jev and GLiNER, one after the other, by trying them on typed + * requests: as fast, and every field right on nine in ten where GLiNER managed half, since it + * found no title it wasn't told of, no subject and no file's name. The answer is held to a JSON + * schema: the widget's id, and every widget's fields, the others left empty. + */ + +const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; + +const MODEL = 'openai/gpt-oss-120b'; + +// While typing goes on, a newer text is asked about every 300 ms, so a slower answer is never shown. +const TIMEOUT_MS = 5000; + +const MAX_TOKENS = 2000; + +/** A field of the picked widget and what fills it: one value, or each of a list's. */ +export type FilledField = {id: string; name: string; values: string[]}; + +export type Intent = { + /** The widget's id, `none` when what was asked is whole and none does it, or `incomplete` when too little was typed to tell yet. */ + widget: string; + /** The widget's fields, each with what fills it; none for `none` and `incomplete`. */ + fields: FilledField[]; + /** How long the model took. */ + ms: number; +}; + +const FIELDS = WIDGETS.flatMap((widget) => widget.fields); + +const INSTRUCTIONS = [ + 'Pick the widget for what the person typed into a command box, and fill in its fields from the text. Copy values from the text, but write the body of an email; never invent a person, place or address. Leave the fields of other widgets empty. Answer with JSON only.', + 'Widgets:', + ...WIDGETS.map((widget) => `- ${widget.id}: ${widget.description}. Fields: ${widget.fields.map((field) => `${field.id}${field.many ? ' (list)' : ''}: ${field.hint}`).join('; ')}`), + `- ${INCOMPLETE}: the text is cut off or says too little to tell what is wanted yet, as it is while still being typed ("find the", "email", "what's")`, + `- ${NO_WIDGET}: a whole request that nothing above does ("summarize my week", "what's the weather tomorrow")`, +].join('\n'); + +function fieldSchema(field: WidgetField) { + if (field.options) { + return {type: ['string', 'null'], enum: [...field.options, null]}; + } + if (field.terms) { + return {type: 'array', items: {type: 'array', items: {type: 'string'}}}; + } + if (field.many) { + return {type: 'array', items: {type: 'string'}}; + } + return {type: ['string', 'null']}; +} + +const JSON_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['widget', ...FIELDS.map((field) => field.id)], + properties: { + widget: {type: 'string', enum: [...WIDGETS.map((widget) => widget.id), INCOMPLETE, NO_WIDGET]}, + ...Object.fromEntries(FIELDS.map((field) => [field.id, fieldSchema(field)])), + }, +}; + +const completionSchema = z.object({choices: z.array(z.object({message: z.object({content: z.string()})})).min(1)}); + +// The widget's id, and each field's value by its id. +const answerSchema = z.object({widget: z.string()}).catchall(z.union([z.string(), z.array(z.string()), z.array(z.array(z.string())), z.null()])); + +type AnswerValue = z.infer[string]; + +// Each value trimmed, once, and none empty. +function unique(values: string[]): string[] { + return [...new Set(values.map((one) => one.trim()).filter((one) => one !== ''))]; +} + +// A field's values: its value, each of a list's, or each search term's words joined by " | ". +function cleaned(value: AnswerValue | undefined): string[] { + if (value === null || value === undefined) { + return []; + } + if (!Array.isArray(value)) { + return unique([value]); + } + return unique(value.map((one) => (Array.isArray(one) ? unique(one.map((word) => word.replace(/\|/g, ' '))).join(' | ') : one))); +} + +/** Which widget fits `text`, and its fields as the model fills them in. */ +export async function classifyIntent(text: string): Promise> { + if (!env.OPENROUTER_KEY) { + return Err(ApiErr.unavailable('The home page is not set up: set `OPENROUTER_KEY`.')); + } + + const start = performance.now(); + const answered = await httpRequest( + OPENROUTER_URL, + { + method: 'POST', + headers: {'content-type': 'application/json', authorization: `Bearer ${env.OPENROUTER_KEY}`}, + body: JSON.stringify({ + model: MODEL, + messages: [ + {role: 'system', content: INSTRUCTIONS}, + {role: 'user', content: text}, + ], + temperature: 0, + // Its thinking and every widget's fields count toward it, an email's body among them, so + // it is well past what an answer takes: cut off, the answer would be no JSON at all. + max_tokens: MAX_TOKENS, + // It thinks before answering; a little is enough here, and keeps it fast. + reasoning: {effort: 'low'}, + response_format: {type: 'json_schema', json_schema: {name: 'intent', strict: true, schema: JSON_SCHEMA}}, + provider: {order: ['Cerebras', 'Groq'], allow_fallbacks: true}, + }), + }, + {schema: completionSchema, timeoutMs: TIMEOUT_MS}, + ); + const ms = Math.round(performance.now() - start); + if (answered.isErr()) { + return Err(ApiErr.providerUnreachable(answered.error)); + } + + const content = answered.value.choices[0]?.message.content ?? ''; + const answer = Result.wrap((): unknown => JSON.parse(content)).andThen((json) => parseSchema(answerSchema, json)); + if (answer.isErr()) { + return Err(ApiErr.providerUnreachable(new Error(`The model answered outside the form: ${content.slice(0, 200)}`))); + } + + if (answer.value.widget === INCOMPLETE) { + return Ok({widget: INCOMPLETE, fields: [], ms}); + } + const widget = WIDGETS.find((candidate) => candidate.id === answer.value.widget); + if (!widget) { + return Ok({widget: NO_WIDGET, fields: [], ms}); + } + return Ok({widget: widget.id, fields: widget.fields.map((field) => ({id: field.id, name: field.name, values: cleaned(answer.value[field.id])})), ms}); +} diff --git a/packages/api/src/intent/route.test.ts b/packages/api/src/intent/route.test.ts new file mode 100644 index 00000000..91dd29ca --- /dev/null +++ b/packages/api/src/intent/route.test.ts @@ -0,0 +1,34 @@ +import {beforeEach, describe, expect, mock, test} from 'bun:test'; +import {Ok} from 'ts-results-es'; + +const classifyIntent = mock(); + +mock.module('./classify', () => ({classifyIntent})); + +function makeRequest(body: unknown) { + return {user: {userId: 'user-1'}, body} as never; +} + +beforeEach(() => { + mock.clearAllMocks(); + classifyIntent.mockResolvedValue(Ok({widget: 'none', fields: [], ms: 1})); +}); + +describe('handleClassifyIntentRoute', () => { + test('classifies the text, trimmed', async () => { + const {handleClassifyIntentRoute} = await import('./route'); + const result = await handleClassifyIntentRoute(makeRequest({text: ' who is hugo '})); + + expect(result.isOk()).toBe(true); + expect(classifyIntent).toHaveBeenCalledWith('who is hugo'); + }); + + test('refuses empty text, and text past 500 characters, without asking the models', async () => { + const {handleClassifyIntentRoute} = await import('./route'); + + for (const text of [' ', 'a'.repeat(501)]) { + expect((await handleClassifyIntentRoute(makeRequest({text}))).unwrapErr().kind).toBe('parse_error'); + } + expect(classifyIntent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/intent/route.ts b/packages/api/src/intent/route.ts new file mode 100644 index 00000000..a67d7741 --- /dev/null +++ b/packages/api/src/intent/route.ts @@ -0,0 +1,17 @@ +import type {Result} from 'ts-results-es'; +import {z} from 'zod'; + +import {type ApiError, Do, parseSchema} from '@proxy/utils'; + +import type {AuthenticatedRequest} from '../server/middleware/require-auth'; +import {classifyIntent, type Intent} from './classify'; + +const intentBodySchema = z.object({text: z.string().trim().min(1).max(500)}); + +/** `POST /api/intent` with `{text}` — what the home page's input asks for, as it is typed. */ +export function handleClassifyIntentRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const {text} = $(parseSchema(intentBodySchema, request.body)); + return $(await classifyIntent(text)); + }); +} diff --git a/packages/api/src/intent/widgets.ts b/packages/api/src/intent/widgets.ts new file mode 100644 index 00000000..ce4a4ea7 --- /dev/null +++ b/packages/api/src/intent/widgets.ts @@ -0,0 +1,161 @@ +import {UNITS} from '@proxy/utils/units'; + +/** + * The widgets the home page shows for what a person types, Sana's way: a small, fast LLM picks the + * one that fits and fills in its fields from the text. Most are meant for one kind of data: + * Calendar, Contacts, Gmail or Drive; Math and Convert units are worked out in the web app. Field ids are unique across widgets, as the LLM answers + * every widget's fields in one object. + */ + +export type WidgetField = { + id: string; + name: string; + /** + * What the LLM is told the field holds. Worded by trying them: "without 's" keeps "jacky's + * email address" to Jacky, and "without any date or time" keeps "last week" out of a search. + */ + hint: string; + /** A list, as a meeting's attendees are, one item each. */ + many?: true; + /** + * Search terms, every one of which is to be found, each a list of words any of which will do: + * "my flight to sfo" is (flight, itinerary, e-ticket, booking) and (SFO, San Francisco). Each + * is answered as its words joined by " | ". + */ + terms?: true; + /** The values it can hold, which the model picks one of, or none: a unit's id. */ + options?: string[]; +}; + +export type Widget = { + id: string; + name: string; + /** What the LLM reads to tell the widgets apart. */ + description: string; + fields: WidgetField[]; +}; + +// Times aren't fields: the web app reads them from the whole text, which no field holds whole. A +// change to an event says two, when it is and when it moves to, so those are fields of their own. +// The units Convert units knows, each as the model is told of it: `mi` (mile). +const UNIT_LIST = UNITS.map((unit) => `${unit.id} (${unit.name})`).join(', '); + +export const WIDGETS: Widget[] = [ + { + id: 'new_event', + name: 'New event', + description: 'Schedule a meeting, call or calendar invite with people at a time', + fields: [ + {id: 'attendees', name: 'Attendees', hint: 'each person to invite, one per item, as written (a name or an email address); never the person typing', many: true}, + {id: 'location', name: 'Location', hint: 'where it takes place, as written, or null'}, + {id: 'title', name: 'Title', hint: 'the event\'s name: one it was given, else a short one from what it is ("Lunch"); null when it is only a meeting, call or event'}, + ], + }, + { + id: 'change_event', + name: 'Change event', + description: 'Change an event already in the calendar: move it to another time, rename it, change where it is, or invite people to it or take them off it', + fields: [ + { + id: 'changeWith', + name: 'With', + hint: 'each person the event to change is with, to find it by, as written without \'s ("my 1:1 with arne" is arne); never the person typing, nor someone being invited or taken off', + many: true, + }, + {id: 'changeEvent', name: 'Event', hint: 'what the event to change is, as written, without the people or any day or time ("standup", "lunch"); null when it is only a meeting, call or event'}, + {id: 'changeFrom', name: 'When', hint: 'when the event to change is now, as written ("friday", "tomorrow\'s"); null when not said'}, + {id: 'changeTo', name: 'New time', hint: 'the time it moves to, as written ("thursday at 3", "10am", "an hour later"); null when its time stays'}, + {id: 'invite', name: 'Invite', hint: 'each person to invite to it, one per item, as written (a name or an email address)', many: true}, + {id: 'uninvite', name: 'Take off', hint: 'each person to take off it, one per item, as written (a name or an email address)', many: true}, + {id: 'newTitle', name: 'New title', hint: 'its new name when it is renamed, else null'}, + {id: 'newLocation', name: 'New location', hint: 'where it moves to, as written, else null'}, + ], + }, + { + id: 'show_calendar', + name: 'Calendar', + description: 'See what is on the calendar on a day, or find when a meeting or event is: what is on today or tomorrow, when the person is meeting someone, when their next dentist appointment is', + fields: [ + {id: 'meetingWith', name: 'With', hint: "each person the event is with, one per item, as written (a name or an email address) without 's; never the person typing", many: true}, + { + id: 'eventName', + name: 'Event', + hint: 'what the event is, as written, without the people or any day or time ("dentist", "standup", "flight to SFO"); null when it is only a meeting, call or event, or only a day is asked about', + }, + ], + }, + { + id: 'find_person', + name: 'Person', + description: 'Look up who someone is, or their email address or phone number', + fields: [{id: 'person', name: 'Person', hint: "the person's name or address alone, without 's or what is asked about them"}], + }, + { + id: 'write_email', + name: 'Write email', + description: 'Write, draft or send a new email to someone', + fields: [ + {id: 'to', name: 'To', hint: 'each recipient, one per item, as written (a name or an email address)', many: true}, + {id: 'subject', name: 'Subject', hint: 'a short subject from what the email is about, or null'}, + { + id: 'body', + name: 'Message', + hint: 'the email itself, written for the person in their voice from what they ask it to say, as short as they ask, greeting every recipient once by first name ("Hi Jon and Jacky,"), without a sign-off or placeholders such as [Your name]; null when they say nothing of what it says', + }, + ], + }, + { + id: 'find_email', + name: 'Find email', + description: 'Find, search for or open emails already received or sent', + fields: [ + {id: 'from', name: 'From', hint: 'who sent it by name or address, as written (a person or a company); null for one described rather than named, such as "my landlord"'}, + { + id: 'about', + name: 'About', + hint: 'what to search its text for, without the sender or any date or time: a list of things it is about, all of which must be in it, each thing a list of other words for that same one thing, any of which will do; "my flight to sfo" is [["flight", "itinerary", "e-ticket", "booking"], ["SFO", "San Francisco"]], "the signed nda" is [["NDA", "non-disclosure"], ["signed", "signature"]]; empty when only the sender or a time is said', + terms: true, + }, + ], + }, + { + id: 'find_file', + name: 'Find file', + description: 'Find or open a file, document, spreadsheet or presentation in Drive', + fields: [ + {id: 'name', name: 'Name', hint: "the file's name, or the words in it, as written, without its owner or 's"}, + {id: 'owner', name: 'Owner', hint: "who made, owns or shared it, as written without 's, or null"}, + ], + }, + { + id: 'calculate', + name: 'Math', + description: 'Work out arithmetic or math: sums, percentages, tips, splitting a bill, powers, roots', + fields: [ + { + id: 'expression', + name: 'Calculation', + hint: 'the calculation as one expression of numbers, + - * / ^, parentheses, % for percent and pi, sqrt(), ln(), log(), sin() and the like; "18% tip on $86.40" is "18% * 86.40", "split 120 four ways" is "120 / 4"', + }, + ], + }, + { + id: 'convert_units', + name: 'Convert units', + description: 'Convert a quantity from one unit to another: length, weight, volume, temperature, speed, area, time, data, energy, or money from one currency to another', + fields: [ + {id: 'amount', name: 'Amount', hint: 'the number to convert, in digits ("five" is "5"); "1" when only units are said'}, + {id: 'fromUnit', name: 'From', hint: `the unit it is in, one of: ${UNIT_LIST}`, options: UNITS.map((unit) => unit.id)}, + {id: 'toUnit', name: 'To', hint: 'the unit to convert it to, of the same list; null when not said', options: UNITS.map((unit) => unit.id)}, + ], + }, +]; + +/** The answer when what was asked is whole and none of the widgets does it. */ +export const NO_WIDGET = 'none'; + +/** + * The answer when the text is cut off or says too little to tell yet, as it is half-way through + * typing: the home page waits for more rather than saying nothing fits. + */ +export const INCOMPLETE = 'incomplete'; diff --git a/packages/api/src/records/email-connector.test.ts b/packages/api/src/records/email-connector.test.ts index 71b75759..8265bd81 100644 --- a/packages/api/src/records/email-connector.test.ts +++ b/packages/api/src/records/email-connector.test.ts @@ -7,8 +7,9 @@ import {findCollection, findIntegration, type Collection} from '@proxy/integrati // A mailbox in memory: the folders the server marks, each folder's messages by UID, and the one // message the record tests open. type Folder = {path: string; specialUse?: string}; -type Stored = {uid: number; date: string; subject: string}; -type Criteria = {all?: boolean; uid?: string; text?: string; gmraw?: string}; +type Address = {name?: string; address: string}; +type Stored = {uid: number; date: string; subject: string; from?: Address[]; to?: Address[]}; +type Criteria = {all?: boolean; uid?: string; text?: string; gmraw?: string; from?: string; since?: string; or?: Criteria[]; not?: Criteria; to?: string; cc?: string}; const UID_VALIDITY = 7n; @@ -17,6 +18,10 @@ const imap = { boxes: {} as Record, gmail: false, opened: '', + logins: 0, + logouts: 0, + closes: 0, + fetches: 0, searches: [] as Criteria[], calls: [] as Array<[string, ...unknown[]]>, message: null as null | {uid: number; flags: Set; source: Buffer}, @@ -32,9 +37,15 @@ class FakeImapFlow { return new Set(imap.gmail ? ['X-GM-EXT-1'] : []); } on() {} - close() {} - async connect() {} - async logout() {} + close() { + imap.closes += 1; + } + async connect() { + imap.logins += 1; + } + async logout() { + imap.logouts += 1; + } async list() { return imap.folders; } @@ -44,22 +55,32 @@ class FakeImapFlow { this.mailbox = {path, uidValidity: UID_VALIDITY, exists: uids.length, uidNext: Math.max(0, ...uids) + 1}; return {release: () => {}}; } - // Matches the subject for a search, as a server would the headers and text. + // Matches the subject for a search, as a server would the headers and text, and for an OR of + // FROM and TO, the sender's and recipients' addresses, here all lowercase. async search(criteria: Criteria) { imap.searches.push(criteria); const words = criteria.gmraw ?? criteria.text; const below = criteria.uid ? Number(criteria.uid.split(':')[1]) : Infinity; const path = this.mailbox ? this.mailbox.path : ''; + // Without regard to case, as servers match headers. + const has = (addresses: Address[] | undefined, wanted: string | undefined) => wanted !== undefined && (addresses ?? []).some(({address}) => address.includes(wanted.toLowerCase())); + const involves = (message: Stored, one: Criteria) => has(message.from, one.from) || has(message.to, one.to); return box(path) - .filter((message) => message.uid <= below && (!words || message.subject.includes(words))) + .filter((message) => message.uid <= below && (!words || message.subject.includes(words)) && (!criteria.or || criteria.or.some((one) => involves(message, one)))) .map((message) => message.uid); } // By position (`41:*`), or by UID with `{uid: true}` (`12,9,3`). async fetchAll(range: string, _fields: object, options?: {uid?: boolean}) { + imap.fetches += 1; const path = this.mailbox ? this.mailbox.path : ''; const stored = [...box(path)].sort((a, b) => a.uid - b.uid); const picked = options?.uid ? stored.filter((message) => range.split(',').includes(String(message.uid))) : stored.slice(Number(range.split(':')[0]) - 1); - return picked.map((message) => ({uid: message.uid, flags: new Set(), internalDate: new Date(message.date), envelope: {subject: message.subject, from: [], to: []}})); + return picked.map((message) => ({ + uid: message.uid, + flags: new Set(), + internalDate: new Date(message.date), + envelope: {subject: message.subject, from: message.from ?? [], to: message.to ?? [], cc: [{name: 'Jo Park', address: 'jo@example.com'}]}, + })); } async fetchOne(uid: string) { if (!imap.message || String(imap.message.uid) !== uid) { @@ -146,6 +167,10 @@ beforeEach(() => { Drafts: [{uid: 1, date: '2026-10-03T09:00:00Z', subject: 'In Drafts'}], }; imap.gmail = false; + imap.logins = 0; + imap.logouts = 0; + imap.closes = 0; + imap.fetches = 0; imap.searches = []; imap.calls = []; imap.message = {uid: 12, flags: new Set(), source}; @@ -266,6 +291,95 @@ describe('emails', () => { }); }); +describe('findInboxEmails', () => { + test("searches the inbox in IMAP's own terms, newest first, at most as many as asked for", async () => { + imap.boxes.INBOX = fill(6, '2026-10-01T09:00:00Z'); + const {findInboxEmails} = await import('./email-connector'); + const [found = []] = (await findInboxEmails(target, [{from: 'sam', words: [['Note']], since: '2026-10-01'}], 3)).unwrap(); + + expect(imap.opened).toBe('INBOX'); + // IMAP ANDs one object's keys, so each further condition goes in as NOT NOT (…). + expect(imap.searches).toEqual([{from: 'sam', not: {not: {text: 'Note', not: {not: {since: '2026-10-01'}}}}}]); + expect(found.map((record) => record.values.subject)).toEqual(['Note 6', 'Note 5', 'Note 4']); + // With who was copied, which the widgets find people in. + expect(found[0]?.values.cc).toBe('Jo Park '); + }); + + test("searches in Gmail's syntax on Gmail, and everything without a search", async () => { + const {findInboxEmails} = await import('./email-connector'); + imap.gmail = true; + await findInboxEmails(target, [{from: 'sam', words: [['Note']], since: null}], 3); + await findInboxEmails(target, [{from: null, words: [], since: null}], 3); + imap.gmail = false; + await findInboxEmails(target, [{from: null, words: [], since: null}], 3); + await findInboxEmails(target, [{from: null, involving: 'jacky', words: [], since: null}], 3); + + await findInboxEmails(target, [{from: null, words: [['flight', 'itinerary'], ['SFO']], since: null}], 3); + + expect(imap.searches).toEqual([ + {gmraw: 'from:sam Note'}, + {all: true}, + {all: true}, + {or: [{from: 'jacky'}, {to: 'jacky'}, {cc: 'jacky'}]}, + {or: [{text: 'flight'}, {text: 'itinerary'}], not: {not: {text: 'SFO'}}}, + ]); + }); + + test('closes the connection when the search is given up on, rather than signing out after it', async () => { + const {findInboxEmails} = await import('./email-connector'); + const controller = new AbortController(); + const finding = findInboxEmails(target, [{from: 'sam', words: [], since: null}], 3, controller.signal); + controller.abort(); + await finding; + + expect(imap.closes).toBe(1); + expect(imap.logouts).toBe(0); + }); + + test('runs every search over one sign-in, answering what each found in the order asked', async () => { + imap.boxes.INBOX = [ + {uid: 1, date: '2026-10-01T09:00:00Z', subject: 'Lunch with Jacky'}, + {uid: 2, date: '2026-10-02T09:00:00Z', subject: 'Notes for Sam'}, + ]; + const {findInboxEmails} = await import('./email-connector'); + const search = (words: string) => ({from: null, words: [[words]], since: null}); + const found = (await findInboxEmails(target, [search('Sam'), search('Jacky'), search('Robin')], 10)).unwrap(); + + expect(imap.logins).toBe(1); + expect(found.map((records) => records.map((record) => record.values.subject))).toEqual([['Notes for Sam'], ['Lunch with Jacky'], []]); + }); + + test('looks for several people in one search, and anyone it leaves short behind someone written to more on their own', async () => { + const me = {address: 'me@example.com'}; + const jacky = {name: 'Jacky Doe', address: 'jacky@example.com'}; + const hugo = {name: 'Hugo Brandt', address: 'hugo@example.com'}; + const {findInboxEmails} = await import('./email-connector'); + const involving = (someone: string) => ({from: null, involving: someone, words: [], since: null}); + const subjects = (found: Array}>>) => found.map((records) => records.map((record) => record.values.subject)); + const either = (someone: string) => [{from: someone}, {to: someone}, {cc: someone}]; + + imap.boxes.INBOX = [ + {uid: 1, date: '2026-09-01T09:00:00Z', subject: 'From Hugo', from: [hugo], to: [me]}, + {uid: 2, date: '2026-10-01T09:00:00Z', subject: 'From Jacky', from: [jacky], to: [me]}, + ]; + const together = (await findInboxEmails(target, [involving('jacky'), involving('HUGO')], 2)).unwrap(); + expect(imap.searches).toEqual([{or: [...either('jacky'), ...either('HUGO')]}]); + expect(subjects(together)).toEqual([['From Jacky'], ['From Hugo']]); + expect(imap.fetches).toBe(1); + + imap.searches = []; + imap.fetches = 0; + imap.boxes.INBOX = [ + {uid: 1, date: '2026-09-01T09:00:00Z', subject: 'From Hugo', from: [hugo], to: [me]}, + ...Array.from({length: 10}, (_, index) => ({uid: index + 2, date: '2026-10-01T09:00:00Z', subject: `Jacky ${index + 1}`, from: [jacky], to: [me]})), + ]; + const crowded = (await findInboxEmails(target, [involving('jacky'), involving('hugo')], 2)).unwrap(); + expect(imap.searches).toEqual([{or: [...either('jacky'), ...either('hugo')]}, {or: either('hugo')}]); + expect(subjects(crowded)).toEqual([['Jacky 10', 'Jacky 9'], ['From Hugo']]); + expect(imap.logins).toBe(2); + }); +}); + test('only drafts are edited or deleted', async () => { const {emailConnector} = await import('./email-connector'); const updated = await emailConnector.update(target, INBOX_EMAIL, {to: '', subject: 'Hi', body: ''}); diff --git a/packages/api/src/records/email-connector.ts b/packages/api/src/records/email-connector.ts index 3b4d4a2a..94b201f1 100644 --- a/packages/api/src/records/email-connector.ts +++ b/packages/api/src/records/email-connector.ts @@ -14,6 +14,7 @@ import {imapClientOptions, imapError, smtpError, smtpTransport} from '../connect import {log, serializeError} from '../observability/log'; import {decryptSecret} from '../utils/secret-crypto'; import type {CommandRunner, Connector, DataRecord, ListQuery, RecordPage, RecordTarget, RecordValues} from './connector'; +import {gmailQuery, type EmailSearch} from './email-search'; /** * A mailbox over IMAP, signed in fresh for every request. Its one list is the inbox and the @@ -84,8 +85,15 @@ async function pathOf(client: ImapFlow, folder: Folder): Promise(target: RecordTarget, work: (client: ImapFlow, credential: EmailCredential) => Promise>): Promise> { +/** + * Signs in, runs `work`, and always signs out again; once `signal` says the work was given up on, + * the connection is closed there and then, rather than left open until the work is done. + */ +async function withClient( + target: RecordTarget, + work: (client: ImapFlow, credential: EmailCredential) => Promise>, + signal?: AbortSignal, +): Promise> { const credential = readCredential(target); if (credential.isErr()) { return credential; @@ -93,13 +101,20 @@ async function withClient(target: RecordTarget, work: (client: ImapFlow, cred const client = new ImapFlow(imapClientOptions(credential.value)); client.on('error', (error: unknown) => log.warn('imap socket error', serializeError(error))); + const close = () => client.close(); + signal?.addEventListener('abort', close, {once: true}); const connected = await Result.wrapAsync(() => client.connect()); if (connected.isErr()) { + signal?.removeEventListener('abort', close); client.close(); return Err(imapError(connected.error)); } const outcome = (await Result.wrapAsync(() => work(client, credential.value))).mapErr((cause) => ApiErr.providerUnreachable(cause)).andThen((result) => result); + signal?.removeEventListener('abort', close); + if (signal?.aborted) { + return outcome; + } await Result.wrapAsync(() => client.logout()); return outcome; } @@ -116,8 +131,8 @@ async function inFolder(client: ImapFlow, credential: EmailCredential, folder }); } -function withFolder(target: RecordTarget, folder: Folder, work: (session: Session) => Promise>): Promise> { - return withClient(target, (client, credential) => inFolder(client, credential, folder, work)); +function withFolder(target: RecordTarget, folder: Folder, work: (session: Session) => Promise>, signal?: AbortSignal): Promise> { + return withClient(target, (client, credential) => inFolder(client, credential, folder, work), signal); } function recordId(session: Session, uid: number): string { @@ -205,10 +220,12 @@ function searchCriteria(client: ImapFlow, search: string | null): SearchObject { return {text: search}; } +// What a list reads of each message. +const SUMMARY_FIELDS = {uid: true, envelope: true, internalDate: true, flags: true}; + /** A folder's newest messages below the UID `before`, matching the search, and whether it has more. */ async function folderPage(session: Session, search: string | null, before: number | null): Promise<{messages: FetchMessageObject[]; more: boolean}> { const {client, mailbox} = session; - const fields = {uid: true, envelope: true, internalDate: true, flags: true}; // The common case, the newest messages: read by position, without a search. if (!search && before === null) { @@ -216,7 +233,7 @@ async function folderPage(session: Session, search: string | null, before: numbe return {messages: [], more: false}; } const range = `${Math.max(1, mailbox.exists - PAGE_SIZE + 1)}:*`; - return {messages: await client.fetchAll(range, fields), more: mailbox.exists > PAGE_SIZE}; + return {messages: await client.fetchAll(range, SUMMARY_FIELDS), more: mailbox.exists > PAGE_SIZE}; } if (before !== null && before <= 1) { @@ -228,7 +245,123 @@ async function folderPage(session: Session, search: string | null, before: numbe if (uids.length === 0) { return {messages: [], more: false}; } - return {messages: await client.fetchAll(uids.join(','), fields, {uid: true}), more: found.length > PAGE_SIZE}; + return {messages: await client.fetchAll(uids.join(','), SUMMARY_FIELDS, {uid: true}), more: found.length > PAGE_SIZE}; +} + +// Any one of `conditions`. +function anyOf(conditions: SearchObject[]): SearchObject { + return conditions.length === 1 && conditions[0] ? conditions[0] : {or: conditions}; +} + +// Every one of `conditions`. IMAP ANDs the keys of one object, so conditions that would share a key, +// as two ORs do, go together as NOT NOT (…), which IMAP reads as the condition itself. +function allOf(conditions: SearchObject[]): SearchObject { + const [first, ...rest] = conditions; + if (!first) { + return {all: true}; + } + if (rest.length === 0) { + return first; + } + return {...first, not: {not: allOf(rest)}}; +} + +// A home page search in IMAP's own terms: from the sender, from, to or copied to someone, any of +// each term's words anywhere in the email, received since the day. +function imapCriteria(search: EmailSearch): SearchObject { + const {involving} = search; + return allOf([ + ...(search.from ? [{from: search.from}] : []), + ...(involving ? [anyOf([{from: involving}, {to: involving}, {cc: involving}])] : []), + ...search.words.filter((term) => term.length > 0).map((term) => anyOf(term.map((word) => ({text: word})))), + ...(search.since ? [{since: search.since}] : []), + ]); +} + +// A record found for the home page's widgets, with who was copied, which they find people in. +function withCc(record: DataRecord, message: FetchMessageObject): DataRecord { + return {...record, values: {...record.values, cc: formatAddresses(message.envelope?.cc, true)}}; +} + +// A home page search in the server's terms: Gmail's own syntax where it takes it, else IMAP's. +// Gmail is asked for every email by an empty search, which X-GM-RAW won't take. +function findCriteria(client: ImapFlow, search: EmailSearch): SearchObject { + const query = isGmail(client) ? gmailQuery(search) : ''; + if (query !== '') { + return {gmraw: query}; + } + return imapCriteria(search); +} + +// The newest emails in the open inbox one search finds, at most `limit`. +async function findInSession(session: Session, search: EmailSearch, limit: number): Promise { + const {client} = session; + const criteria = findCriteria(client, search); + const uids = ((await client.search(criteria, {uid: true})) || []).sort((a, b) => b - a).slice(0, limit); + if (uids.length === 0) { + return []; + } + const messages = await client.fetchAll(uids.join(','), SUMMARY_FIELDS, {uid: true}); + return messages.sort((a, b) => b.uid - a.uid).map((message) => withCc(toRecord(session, message), message)); +} + +// A search only for someone the email is from, to or copied to, which the envelope alone tells. +function onlyInvolving(search: EmailSearch): search is EmailSearch & {involving: string} { + return Boolean(search.involving) && !search.from && search.words.length === 0 && !search.since; +} + +// Whether the email is from, to or copied to someone, as IMAP's FROM, TO and CC match: the words +// anywhere in the header, its names or addresses, whatever their case. +function involves(message: FetchMessageObject, someone: string): boolean { + const {envelope} = message; + const wanted = someone.toLowerCase(); + return [...(envelope?.from ?? []), ...(envelope?.to ?? []), ...(envelope?.cc ?? [])].some(({name, address}) => `${name ?? ''} <${address ?? ''}>`.toLowerCase().includes(wanted)); +} + +/** + * The newest emails each of `searches`, each only for someone, finds, at most `limit` each, from + * one IMAP search for any of them: the newest of their emails, as many as all of them could want, + * each going to everyone it involves. Someone those leave short, behind someone written to more, + * is searched for on their own, so each still gets their own newest. + */ +async function findEveryone(session: Session, searches: Array, limit: number): Promise { + const {client} = session; + const criteria: SearchObject = {or: searches.flatMap(({involving}) => [{from: involving}, {to: involving}, {cc: involving}])}; + const uids = ((await client.search(criteria, {uid: true})) || []).sort((a, b) => b - a); + const newest = uids.slice(0, limit * searches.length); + const messages = newest.length === 0 ? [] : (await client.fetchAll(newest.join(','), SUMMARY_FIELDS, {uid: true})).sort((a, b) => b.uid - a.uid); + const found: DataRecord[][] = []; + for (const search of searches) { + const theirs = messages.filter((message) => involves(message, search.involving)).slice(0, limit); + const short = theirs.length < limit && uids.length > newest.length; + found.push(short ? await findInSession(session, search, limit) : theirs.map((message) => withCc(toRecord(session, message), message))); + } + return found; +} + +/** + * The newest emails in the inbox each home page search finds, at most `limit` each: in Gmail's + * syntax on Gmail, else in IMAP's own terms. One sign-in runs them all, as servers allow an + * account only so many connections at once: searches only for people, as the People widget's, + * as one search for any of them where it can, any others one after the other. + */ +export function findInboxEmails(target: RecordTarget, searches: EmailSearch[], limit: number, signal?: AbortSignal): Promise> { + return withFolder( + target, + INBOX, + async (session) => { + const people = searches.filter(onlyInvolving); + if (searches.length > 1 && people.length === searches.length) { + return Ok(await findEveryone(session, people, limit)); + } + const found: DataRecord[][] = []; + for (const search of searches) { + found.push(await findInSession(session, search, limit)); + } + return Ok(found); + }, + signal, + ); } // Where each folder goes on, in a page token: the folder, its UIDVALIDITY, and the UID listing diff --git a/packages/api/src/records/email-search.ts b/packages/api/src/records/email-search.ts new file mode 100644 index 00000000..f633f013 --- /dev/null +++ b/packages/api/src/records/email-search.ts @@ -0,0 +1,31 @@ +/** + * A search for email as the home page asks it, whatever the mailbox: from someone, or involving + * them (from, to or copied), about some words, since a day. Each mailbox's connector puts it in its own terms: Gmail's search syntax, + * over its API or over IMAP, or IMAP's own search elsewhere. + */ +export type EmailSearch = { + // A name or an address, as the sender is written. + from: string | null; + // A name or an address of someone the email is from, to or copied to: the people a name stands + // for, among those written to and heard from. + involving?: string; + // Terms every one of which is to be found, each a list of words any of which will do. + words: string[][]; + // A day, 2026-10-07: email from then on. + since: string | null; +}; + +function quoted(text: string): string { + return /\s/.test(text) ? `"${text.replace(/"/g, '')}"` : text; +} + +/** The search in Gmail's own syntax, as typed in its search box: `from:"Sam Lee" (invoice OR bill) after:2026/10/07`. */ +export function gmailQuery(search: EmailSearch): string { + const involving = search.involving ? quoted(search.involving) : null; + return [ + ...(search.from ? [`from:${quoted(search.from)}`] : []), + ...(involving ? [`{from:${involving} to:${involving} cc:${involving}}`] : []), + ...search.words.flatMap((term) => (term.length > 1 ? [`(${term.map(quoted).join(' OR ')})`] : term.map(quoted))), + ...(search.since ? [`after:${search.since.replace(/-/g, '/')}`] : []), + ].join(' '); +} diff --git a/packages/api/src/records/google-emails.test.ts b/packages/api/src/records/google-emails.test.ts index 70b70b45..dc988592 100644 --- a/packages/api/src/records/google-emails.test.ts +++ b/packages/api/src/records/google-emails.test.ts @@ -63,10 +63,12 @@ const html = (body: string): Part => ({mimeType: 'text/html', filename: '', head // A mailbox as Gmail answers it: an unread email from Sam in the inbox, one sent, and a draft. function mailbox(): Record { return { - m1: message('m1', ['UNREAD', 'IMPORTANT', 'INBOX'], {Subject: 'Q3 planning', From: '"Sam Lee" ', To: 'Alex Kim ', Date: 'Thu, 1 Oct 2026 09:00:00 +0000'}, [ - text('Agenda attached.\n'), - html('

Agenda attached.

'), - ]), + m1: message( + 'm1', + ['UNREAD', 'IMPORTANT', 'INBOX'], + {Subject: 'Q3 planning', From: '"Sam Lee" ', To: 'Alex Kim ', Cc: 'Jo Park ', Date: 'Thu, 1 Oct 2026 09:00:00 +0000'}, + [text('Agenda attached.\n'), html('

Agenda attached.

')], + ), m2: message('m2', ['SENT'], {Subject: 'Re: dinner', From: 'Alex Kim ', To: 'Robin '}, [html('

See you Friday at 8.

')]), m3: message( 'm3', @@ -173,13 +175,29 @@ async function connector() { return googleConnector; } +describe('findGmailEmails', () => { + test("searches all mail in Gmail's syntax and reads only as many as asked for, by their message ids", async () => { + const {findGmailEmails} = await import('./google-emails'); + const [found = []] = (await findGmailEmails(target, [{from: 'Sam Lee', words: [['planning']], since: '2026-09-01'}], 2)).unwrap(); + + const [list] = calls('GET', '/messages'); + expect(Object.fromEntries(list?.url.searchParams ?? [])).toEqual({maxResults: '2', q: 'from:"Sam Lee" planning after:2026/09/01'}); + // With who was copied, which the widgets find people in, and Gmail's page for each. + expect(found.map((record) => [record.id, record.values.subject, record.values.cc, record.url])).toEqual([ + ['m1', 'Q3 planning', 'Jo Park ', 'https://mail.google.com/mail/?authuser=alex%40example.com#all/m1'], + ['m2', 'Re: dinner', '', 'https://mail.google.com/mail/?authuser=alex%40example.com#all/m2'], + ]); + expect(calls('GET', '/messages/m3')).toHaveLength(0); + }); +}); + describe('list', () => { test('a folder is its label, the search Gmail’s own, from the page token given, each message read for its headers', async () => { const page = (await (await connector()).list(target, query({filter: 'Inbox', search: 'from:sam', page: 'page-1'}))).unwrap(); const [list] = calls('GET', '/messages'); expect(Object.fromEntries(list?.url.searchParams ?? [])).toEqual({maxResults: '50', labelIds: 'INBOX', q: 'from:sam', pageToken: 'page-1'}); - expect(calls('GET', '/messages/m1')[0]?.url.searchParams.getAll('metadataHeaders')).toEqual(['Subject', 'From', 'To']); + expect(calls('GET', '/messages/m1')[0]?.url.searchParams.getAll('metadataHeaders')).toEqual(['Subject', 'From', 'To', 'Cc']); expect(calls('GET', '/messages/m1')[0]?.url.searchParams.get('format')).toBe('metadata'); expect(page.nextPage).toBe('page-2'); expect(page.records[0]).toEqual({ diff --git a/packages/api/src/records/google-emails.ts b/packages/api/src/records/google-emails.ts index ac9916e5..15d219bc 100644 --- a/packages/api/src/records/google-emails.ts +++ b/packages/api/src/records/google-emails.ts @@ -5,7 +5,7 @@ import MailComposer from 'nodemailer/lib/mail-composer'; import type Mail from 'nodemailer/lib/mailer'; import {Err, Ok, Result} from 'ts-results-es'; -import {ApiErr, type ApiError, Do, requirePresent} from '@proxy/utils'; +import {ApiErr, type ApiError, Do, mapWithLimit, requirePresent} from '@proxy/utils'; import {callGoogle} from '../connections/google/google-client'; import { @@ -26,6 +26,7 @@ import { messageBody, messageHtml, modifyMessage, + PAGE_SIZE, rawMessage, sendDraft, sendMessage, @@ -35,6 +36,7 @@ import { type MessagePart, } from '../connections/google/gmail-api'; import type {CommandRunner, Connector, DataRecord, ListQuery, RecordPage, RecordTarget, RecordValues} from './connector'; +import {gmailQuery, type EmailSearch} from './email-search'; /** * Gmail as the `emails` collection, fetched live, the same email as any mailbox's: one list, 50 to @@ -215,6 +217,27 @@ function listPage(target: RecordTarget, query: ListQuery): Promise> { + return Do(async ($) => { + const page = $(await listMessageIds(token, folderQuery(null, gmailQuery(search)), null, Math.min(limit, PAGE_SIZE))); + const messages = $(present(await getMessageSummaries(token, page.items.slice(0, limit)))); + return messages.flatMap((message) => (message ? [toRecord(account, message.id, message, {...summaryValues(message), cc: formatAddresses(headerOf(message.payload, 'Cc'), true)})] : [])); + }); +} + +/** + * The newest emails each search in Gmail's syntax finds, all mail but Spam and Trash, at most + * `limit` each, read with their headers alone: a list of a page would read 50. Drafts keep their + * message ids, as they are only shown. + */ +export function findGmailEmails(target: RecordTarget, searches: EmailSearch[], limit: number): Promise> { + return callGoogle(target.connection, async (token) => Result.all(await mapWithLimit(searches, SEARCHES_AT_ONCE, (search) => findWithToken(token, target.connection.account, search, limit)))); +} + function readDraft(token: string, account: string, recordId: string, draftId: string): Promise> { return Do(async ($) => { const draft = $((await getDraft(token, draftId)).mapErr(notFoundAs(recordId))); diff --git a/packages/api/src/records/google-events.ts b/packages/api/src/records/google-events.ts index 6bca2877..d92cd3e9 100644 --- a/packages/api/src/records/google-events.ts +++ b/packages/api/src/records/google-events.ts @@ -2,6 +2,7 @@ import {Err, Ok, Result} from 'ts-results-es'; import {z} from 'zod'; import {ApiErr, type ApiError, Do} from '@proxy/utils'; +import {addDays} from '@proxy/utils/days'; import { getCalendar, @@ -185,13 +186,6 @@ function eventRecord(calendarId: string, event: GoogleEvent): DataRecord { return {id: eventRecordId(calendarId, event.id), values: eventValues(event), updatedAt: event.updated ?? NEVER, ...(event.htmlLink ? {url: event.htmlLink} : {})}; } -// A day, moved by `days`. -function addDays(day: string, days: number): string { - const date = new Date(`${day}T00:00:00Z`); - date.setUTCDate(date.getUTCDate() + days); - return date.toISOString().slice(0, 10); -} - // A time as written: a day for an all-day event, or a time, with its offset or on the calendar's clock. type Written = {kind: 'day'; date: string} | {kind: 'time'; dateTime: string; offset: boolean}; diff --git a/packages/api/src/records/google-files.ts b/packages/api/src/records/google-files.ts index 1e1e486a..7c902ed6 100644 --- a/packages/api/src/records/google-files.ts +++ b/packages/api/src/records/google-files.ts @@ -123,7 +123,7 @@ function withDrive(target: RecordTarget, run: (drive: Drive) => Promise => Ok(undefined)); +const saveWriteTarget = mock(async (): Promise => Ok(undefined)); + +mock.module('@proxy/db/audit', () => ({logProxyAction})); +mock.module('@proxy/db/write-target', () => ({saveWriteTarget})); + +const source = {connection: {id: 'work'}, collection: {id: 'events'}} as never; + +describe('afterWrite', () => { + test("logs the write as Personal Agent Proxy's and keeps where it went", async () => { + const {afterWrite} = await import('./writes'); + + await afterWrite({orgId: 'org-1', userId: 'user-1', source, action: 'create', title: 'Lunch', parent: 'team'}); + expect(logProxyAction).toHaveBeenCalledWith({orgId: 'org-1', connectionId: 'work', collectionId: 'events', action: 'create', recordTitle: 'Lunch'}); + expect(saveWriteTarget).toHaveBeenCalledWith({userId: 'user-1', collectionId: 'events', connectionId: 'work', parent: 'team'}); + }); + + test('fails nothing when the log or the place written to cannot be kept', async () => { + logProxyAction.mockResolvedValueOnce(Err(ApiErr.dbError(new Error('down')))); + saveWriteTarget.mockResolvedValueOnce(Err(ApiErr.dbError(new Error('down')))); + const {afterWrite} = await import('./writes'); + + expect(await afterWrite({orgId: 'org-1', userId: 'user-1', source, action: 'sendNew', title: '', parent: null})).toBeUndefined(); + }); +}); diff --git a/packages/api/src/widgets/agenda.test.ts b/packages/api/src/widgets/agenda.test.ts new file mode 100644 index 00000000..4965d03c --- /dev/null +++ b/packages/api/src/widgets/agenda.test.ts @@ -0,0 +1,105 @@ +import {beforeEach, describe, expect, mock, test} from 'bun:test'; +import {Ok} from 'ts-results-es'; + +const findSources = mock(); +const googleCalendars = {agenda: mock(), search: mock()}; +const getWriteTarget = mock(async (): Promise => Ok(null)); + +mock.module('./sources', () => ({findSources, summarize: (source: {connection: {id: string}}) => ({connectionId: source.connection.id})})); +mock.module('./google-calendars', () => ({googleCalendars})); +mock.module('@proxy/db/write-target', () => ({getWriteTarget})); +mock.module('../utils/user-org', () => ({requireUserOrgId: async () => Ok('org-1')})); + +const calendar = (connectionId: string, id: string, primary: boolean, writable: boolean) => ({connectionId, id, name: id, primary, writable, color: null}); + +beforeEach(() => { + mock.clearAllMocks(); + getWriteTarget.mockResolvedValue(Ok(null)); +}); + +describe('pickTarget', () => { + test('keeps the calendar last written to, else the first primary one written to, else any', async () => { + const {pickTarget} = await import('./agenda'); + const calendars = [calendar('home', 'shared', false, false), calendar('home', 'team', false, true), calendar('work', 'me@work', true, true)]; + + expect(pickTarget(calendars, {connectionId: 'home', parent: 'team'})).toEqual({connectionId: 'home', calendarId: 'team'}); + expect(pickTarget(calendars, null)).toEqual({connectionId: 'work', calendarId: 'me@work'}); + expect(pickTarget(calendars, {connectionId: 'home', parent: 'shared'})).toEqual({connectionId: 'work', calendarId: 'me@work'}); + expect(pickTarget([calendar('home', 'team', false, true)], null)).toEqual({connectionId: 'home', calendarId: 'team'}); + expect(pickTarget([calendar('home', 'shared', false, false)], null)).toBeNull(); + }); +}); + +describe('handleAgendaRoute', () => { + const from = '2026-10-15T00:00:00.000+02:00'; + const to = '2026-10-16T00:00:00.000+02:00'; + + test('gathers every calendar integration with an adapter, soonest first', async () => { + findSources.mockResolvedValue( + Ok([ + {connection: {id: 'work'}, integration: {id: 'google'}}, + {connection: {id: 'other'}, integration: {id: 'notion'}}, + ]), + ); + googleCalendars.agenda.mockResolvedValue( + Ok({ + calendars: [calendar('work', 'me@work', true, true)], + events: [ + {connectionId: 'work', calendarId: 'me@work', id: 'b', title: 'Later', start: '2026-10-15T15:00:00+02:00', end: '2026-10-15T16:00:00+02:00', allDay: false, link: null}, + {connectionId: 'work', calendarId: 'me@work', id: 'a', title: 'Sooner', start: '2026-10-15T09:00:00+02:00', end: '2026-10-15T10:00:00+02:00', allDay: false, link: null}, + ], + }), + ); + getWriteTarget.mockResolvedValue(Ok({connectionId: 'work', parent: 'me@work'})); + const {handleAgendaRoute} = await import('./agenda'); + + const agenda = (await handleAgendaRoute({user: {userId: 'user-1'}, query: {from, to}} as never)).unwrap(); + expect(googleCalendars.agenda).toHaveBeenCalledTimes(1); + expect(agenda.sources.map((source) => source.connectionId)).toEqual(['work']); + expect(agenda.events.map((event) => event.title)).toEqual(['Sooner', 'Later']); + expect(agenda.target).toEqual({connectionId: 'work', calendarId: 'me@work'}); + expect(getWriteTarget).toHaveBeenCalledWith('user-1', 'events'); + }); + + test('refuses a range past two days, or backwards', async () => { + const {handleAgendaRoute} = await import('./agenda'); + for (const query of [ + {from, to: '2026-10-20T00:00:00.000+02:00'}, + {from: to, to: from}, + ]) { + expect((await handleAgendaRoute({user: {userId: 'user-1'}, query} as never)).unwrapErr().kind).toBe('validation_error'); + } + expect(findSources).not.toHaveBeenCalled(); + }); +}); + +describe('handleFindEventsRoute', () => { + const from = '2026-10-16T00:00:00.000+02:00'; + const event = (connectionId: string, id: string, start: string) => ({connectionId, calendarId: 'me', id, title: id, start, end: start, allDay: false, link: null}); + + test('searches every calendar with each search, soonest first across them', async () => { + findSources.mockResolvedValue( + Ok([ + {connection: {id: 'work'}, integration: {id: 'google'}}, + {connection: {id: 'home'}, integration: {id: 'google'}}, + ]), + ); + googleCalendars.search.mockImplementation(async (source: {connection: {id: string}}) => + Ok(source.connection.id === 'work' ? [event('work', 'later', '2026-10-21T15:00:00+02:00')] : [event('home', 'sooner', '2026-10-17T09:00:00+02:00')]), + ); + const {handleFindEventsRoute} = await import('./agenda'); + + const found = (await handleFindEventsRoute({user: {userId: 'user-1'}, query: {from, search: ['arne', ' arne@example.com ', 'arne']}} as never)).unwrap(); + expect(findSources).toHaveBeenCalledWith('org-1', 'events', 'list'); + expect(googleCalendars.search).toHaveBeenCalledWith(expect.anything(), {from, searches: ['arne', 'arne@example.com']}); + expect(found.events.map((one) => one.id)).toEqual(['sooner', 'later']); + }); + + test('takes one search to three, and a time to search from', async () => { + const {handleFindEventsRoute} = await import('./agenda'); + for (const query of [{from}, {from, search: ['a', 'b', 'c', 'd']}, {search: 'arne'}]) { + expect((await handleFindEventsRoute({user: {userId: 'user-1'}, query} as never)).unwrapErr().kind).toBe('parse_error'); + } + expect(findSources).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/widgets/agenda.ts b/packages/api/src/widgets/agenda.ts new file mode 100644 index 00000000..6ec33f1c --- /dev/null +++ b/packages/api/src/widgets/agenda.ts @@ -0,0 +1,134 @@ +import {Err, type Result} from 'ts-results-es'; +import {z} from 'zod'; + +import {ApiErr, type ApiError, Do, parseSchema} from '@proxy/utils'; +import {getWriteTarget} from '@proxy/db/write-target'; +import type {IntegrationId} from '@proxy/integrations'; + +import type {AuthenticatedRequest} from '../server/middleware/require-auth'; +import {requireUserOrgId} from '../utils/user-org'; +import {eachSource, type Failure} from './each-source'; +import {googleCalendars} from './google-calendars'; +import {findSources, summarize, type Source, type SourceSummary} from './sources'; + +/** + * Every calendar connected, for the New event and Calendar widgets: a stretch of time, the day an + * event is for or the day asked about, with what is in it, and the calendars a new event can go in, + * the one the person last made one in first; and the events coming up that a search finds. + */ + +/** A calendar as the widget shows it: whose, and whether a new event can go in it. */ +export type AgendaCalendar = {connectionId: string; id: string; name: string; primary: boolean; writable: boolean; color: string | null}; + +/** + * An event of a calendar: its times with their offsets, or for an all-day event its first day and + * the day after its last; where it is, everyone invited but the account itself and rooms, by + * their addresses, and whether the person can change it, which they can only of an event they + * organize, in a calendar they can write to. + */ +export type AgendaEvent = { + connectionId: string; + calendarId: string; + id: string; + title: string; + start: string; + end: string; + allDay: boolean; + location: string; + guests: string[]; + editable: boolean; + link: string | null; +}; + +/** + * How an integration's calendars are read; a calendar integration joins the widgets with one: the + * calendars and their events between two times, and the events from a time on that any of the + * searches finds, soonest first. + */ +export type CalendarAdapter = { + agenda: (source: Source, range: {from: string; to: string}) => Promise>; + search: (source: Source, query: {from: string; searches: string[]}) => Promise>; +}; + +const ADAPTERS: Partial> = {google: googleCalendars}; + +export type CalendarTarget = {connectionId: string; calendarId: string}; + +export type Agenda = {sources: SourceSummary[]; calendars: AgendaCalendar[]; events: AgendaEvent[]; target: CalendarTarget | null; failed: Failure[]}; + +// A widget shows a day, or two where an event runs past midnight. +const MAX_RANGE_MS = 2 * 24 * 3_600_000; + +const querySchema = z.object({from: z.iso.datetime({offset: true}), to: z.iso.datetime({offset: true})}); + +/** + * Where a new event goes: the calendar the person last made one in, while they still can, else the + * first primary calendar they can write to, else any. + */ +export function pickTarget(calendars: AgendaCalendar[], last: {connectionId: string; parent: string | null} | null): CalendarTarget | null { + const writable = calendars.filter((calendar) => calendar.writable); + const kept = writable.find((calendar) => calendar.connectionId === last?.connectionId && calendar.id === last.parent); + const picked = kept ?? writable.find((calendar) => calendar.primary) ?? writable[0]; + return picked ? {connectionId: picked.connectionId, calendarId: picked.id} : null; +} + +// Every calendar connected that a widget can read, with how. +async function calendarSources(orgId: string): Promise, ApiError>> { + return (await findSources(orgId, 'events', 'list')).map((sources) => + sources.flatMap((source) => { + const adapter = ADAPTERS[source.integration.id]; + return adapter ? [{...source, adapter}] : []; + }), + ); +} + +/** `GET /api/widgets/agenda?from=…&to=…`: every calendar's events between two times. */ +export function handleAgendaRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const range = $(parseSchema(querySchema, request.query)); + const span = Date.parse(range.to) - Date.parse(range.from); + if (span <= 0 || span > MAX_RANGE_MS) { + return $(Err(ApiErr.validationError('Ask for at most two days, from before to'))); + } + const orgId = $(await requireUserOrgId(request)); + const sources = $(await calendarSources(orgId)); + const {found, failed} = await eachSource(sources, (source) => source.adapter.agenda(source, range)); + const calendars = found.flatMap(({value}) => value.calendars); + const events = found.flatMap(({value}) => value.events).sort((a, b) => Date.parse(a.start) - Date.parse(b.start)); + const last = $(await getWriteTarget(request.user.userId, 'events')); + return {sources: sources.map(summarize), calendars, events, target: pickTarget(calendars, last), failed}; + }); +} + +// What the Calendar widget lists at most, across every calendar. +const FOUND_LIMIT = 10; + +const searchSchema = z.object({ + from: z.iso.datetime({offset: true}), + // Each a search on its own, any of which finds an event: "arne", "arne@example.com". + search: z + .union([z.string(), z.array(z.string())]) + .transform((value) => [...new Set((Array.isArray(value) ? value : [value]).map((one) => one.trim().slice(0, 200)).filter((one) => one !== ''))]) + .pipe(z.array(z.string()).min(1).max(3)), +}); + +export type FoundEvents = {sources: SourceSummary[]; events: AgendaEvent[]; failed: Failure[]}; + +/** + * `GET /api/widgets/events?from=…&search=…&search=…`: the events from a time on that any of the + * searches finds in every calendar, as the calendar's own search finds them (in Google's, by + * title, description, place and the guests' names and addresses), soonest first, each once. + */ +export function handleFindEventsRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const {from, search} = $(parseSchema(searchSchema, request.query)); + const orgId = $(await requireUserOrgId(request)); + const sources = $(await calendarSources(orgId)); + const {found, failed} = await eachSource(sources, (source) => source.adapter.search(source, {from, searches: search})); + const events = found + .flatMap(({value}) => value) + .sort((a, b) => Date.parse(a.start) - Date.parse(b.start)) + .slice(0, FOUND_LIMIT); + return {sources: sources.map(summarize), events, failed}; + }); +} diff --git a/packages/api/src/widgets/each-source.ts b/packages/api/src/widgets/each-source.ts new file mode 100644 index 00000000..504a255f --- /dev/null +++ b/packages/api/src/widgets/each-source.ts @@ -0,0 +1,70 @@ +import {Err, type Result} from 'ts-results-es'; + +import {ApiErr, type ApiError} from '@proxy/utils'; + +import {log, serializeError} from '../observability/log'; +import type {Source} from './sources'; + +/** A source that couldn't be read, and why, in a line the widget shows. */ +export type Failure = {connectionId: string; account: string; message: string}; + +type Gathered = {found: Array<{source: S; value: T}>; failed: Failure[]}; + +// How long a widget waits for one source: one that is slow is left out rather than holding up the rest. +const TIMEOUT_MS = 6_000; + +/** Work on one source, told by `signal` when it is given up on, as an IMAP sign-in is then closed. */ +export type SourceWork = (source: S, signal: AbortSignal) => Promise>; + +// What `work` answers for a source, or a failure once it has taken too long, when it is told to stop. +async function withinTimeout(source: S, work: SourceWork): Promise> { + const controller = new AbortController(); + let timer: ReturnType | undefined; + const timedOut = new Promise>((resolve) => { + timer = setTimeout(() => { + controller.abort(); + resolve(Err(ApiErr.providerUnreachable(new Error('Timed out')))); + }, TIMEOUT_MS); + }); + const result = await Promise.race([work(source, controller.signal), timedOut]); + clearTimeout(timer); + return result; +} + +/** What a widget says of a source it couldn't read. */ +function failureMessage(error: ApiError): string { + if (error.kind === 'credentials_rejected') { + return 'its sign-in has run out: connect it again'; + } + if (error.kind === 'provider_unreachable') { + return 'it could not be reached'; + } + if (error.kind === 'validation_error' || error.kind === 'conflict' || error.kind === 'unavailable' || error.kind === 'mail_error') { + return error.message; + } + return 'something went wrong'; +} + +/** + * Runs `work` on every source at once and gathers what each found. A source that fails, or takes + * longer than six seconds, is a failure beside the rest rather than failing them all, so one + * expired sign-in doesn't empty a widget; one given up on is told to stop. + */ +export async function eachSource(sources: S[], work: SourceWork): Promise> { + const results = await Promise.all(sources.map((source) => withinTimeout(source, work))); + const gathered: Gathered = {found: [], failed: []}; + results.forEach((result, index) => { + const source = sources[index]; + if (!source) { + return; + } + if (result.isOk()) { + gathered.found.push({source, value: result.value}); + return; + } + const {id, integrationId, account} = source.connection; + log.warn('A widget could not read a source', {connectionId: id, integrationId, kind: result.error.kind, ...('cause' in result.error ? serializeError(result.error.cause) : {})}); + gathered.failed.push({connectionId: id, account, message: failureMessage(result.error)}); + }); + return gathered; +} diff --git a/packages/api/src/widgets/edit-event.ts b/packages/api/src/widgets/edit-event.ts new file mode 100644 index 00000000..9ca84c45 --- /dev/null +++ b/packages/api/src/widgets/edit-event.ts @@ -0,0 +1,57 @@ +import {Err, type Result} from 'ts-results-es'; +import {z} from 'zod'; + +import {ApiErr, type ApiError, Do, parseSchema} from '@proxy/utils'; + +import type {AuthenticatedRequest} from '../server/middleware/require-auth'; +import {requireUserOrgId} from '../utils/user-org'; +import type {MadeEvent} from './new-event'; +import {requireWritable} from './sources'; +import {afterWrite} from './writes'; + +/** + * The Change event widget's Save: an event already in a calendar, changed as an agent's edit + * would be, through the calendar's connector, which sends only what is written differently and + * tells the guests. Only an event the person organizes is changed here: one someone else invited + * them to is theirs to answer, not to move. + */ + +const bodySchema = z.object({ + connectionId: z.string().min(1), + calendarId: z.string().min(1), + eventId: z.string().min(1), + title: z.string().trim().min(1).max(500).optional(), + start: z.iso.datetime({offset: true}).optional(), + end: z.iso.datetime({offset: true}).optional(), + location: z.string().trim().max(500).optional(), + // Everyone invited once it is changed, the account itself left out. + guests: z.array(z.email()).max(100).optional(), +}); + +/** `PUT /api/widgets/events`. */ +export function handleEditEventRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const {connectionId, calendarId, eventId, guests, ...rest} = $(parseSchema(bodySchema, request.body)); + const orgId = $(await requireUserOrgId(request)); + const changes = {...rest, ...(guests ? {guests: guests.join(', ')} : {})}; + const written = Object.keys(changes); + const source = $(await requireWritable(orgId, connectionId, 'events', 'update', written)); + const recordId = `${calendarId}:${eventId}`; + const before = $(await source.connector.get(source, recordId)); + if (before.values.kind !== 'Event') { + return $(Err(ApiErr.notFound('record', recordId))); + } + if ((before.values.organizer ?? '') !== '') { + return $(Err(ApiErr.validationError('Only an event you organize can be changed here'))); + } + // Changing an event with guests tells them, which needs Invite guests as writing them does. + if (!written.includes('guests') && (before.values.guests ?? '') !== '') { + $(await requireWritable(orgId, connectionId, 'events', 'update', ['guests'])); + } + const current = Object.fromEntries(source.collection.fields.filter((field) => !field.system).map((field) => [field.key, before.values[field.key] ?? ''])); + const changed = $(await source.connector.update(source, recordId, {...current, ...changes}, before)); + const title = changed.values.title ?? ''; + await afterWrite({orgId, userId: request.user.userId, source, action: 'update', title, parent: undefined}); + return {title, link: changed.url ?? null}; + }); +} diff --git a/packages/api/src/widgets/emails.ts b/packages/api/src/widgets/emails.ts new file mode 100644 index 00000000..9d9dd1d7 --- /dev/null +++ b/packages/api/src/widgets/emails.ts @@ -0,0 +1,124 @@ +import type {Result} from 'ts-results-es'; +import {z} from 'zod'; + +import {ApiErr, type ApiError, Do, parseSchema, requirePresent} from '@proxy/utils'; +import {getWriteTarget} from '@proxy/db/write-target'; + +import type {AuthenticatedRequest} from '../server/middleware/require-auth'; +import {requireUserOrgId} from '../utils/user-org'; +import type {DataRecord} from '../records/connector'; +import type {EmailSearch} from '../records/email-search'; +import {eachSource, type Failure} from './each-source'; +import {searchable} from './mail-searches'; +import {findSources, requireWritable, summarize, type Source, type SourceSummary} from './sources'; +import {afterWrite} from './writes'; + +/** + * Email for the Find email and Write email widgets: a search of every mailbox connected, each in + * its own terms, newest first across them, and an email written from the mailbox picked, saved + * as a draft or sent, through the mailbox's connector as an agent's would be. + */ + +export type FoundEmail = {connectionId: string; id: string; from: string; subject: string; date: string; unread: boolean; link: string | null}; + +// What the widget lists at most, across every mailbox. +const LIMIT = 8; + +function foundEmail(source: Source, record: DataRecord): FoundEmail { + const {values} = record; + return { + connectionId: source.connection.id, + id: record.id, + from: values.from ?? '', + subject: values.subject ?? '', + date: values.date ?? record.updatedAt, + unread: (values.status ?? '').startsWith('Unread'), + link: record.url ?? null, + }; +} + +const searchSchema = z.object({ + from: z.string().trim().max(200).optional(), + words: z.union([z.string(), z.array(z.string())]).optional(), + since: z.iso.date().optional(), +}); + +function readSearch(query: z.infer): EmailSearch { + const words = query.words === undefined ? [] : Array.isArray(query.words) ? query.words : [query.words]; + return { + from: query.from || null, + // Each term's words, any of which will do, joined by " | ". + words: words + .map((term) => + term + .split('|') + .map((word) => word.trim().slice(0, 100)) + .filter((word) => word !== '') + .slice(0, 10), + ) + .filter((term) => term.length > 0) + .slice(0, 10), + since: query.since ?? null, + }; +} + +export type FoundEmails = {sources: SourceSummary[]; emails: FoundEmail[]; failed: Failure[]}; + +/** `GET /api/widgets/emails?from=…&words=…&since=…`: the newest emails each mailbox finds. */ +export function handleFindEmailsRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const search = readSearch($(parseSchema(searchSchema, request.query))); + const orgId = $(await requireUserOrgId(request)); + const sources = searchable($(await findSources(orgId, 'emails', 'list'))); + const {found, failed} = await eachSource(sources, (source, signal) => source.find(source, [search], LIMIT, signal)); + const emails = found + .flatMap(({source, value: [records = []]}) => records.map((record) => foundEmail(source, record))) + .sort((a, b) => Date.parse(b.date) - Date.parse(a.date)) + .slice(0, LIMIT); + return {sources: sources.map(summarize), emails, failed}; + }); +} + +export type Mailboxes = {sources: SourceSummary[]; target: string | null}; + +/** `GET /api/widgets/mailboxes`: the mailboxes email can go out from, the one last written from first. */ +export function handleMailboxesRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const orgId = $(await requireUserOrgId(request)); + const sources = $(await findSources(orgId, 'emails', 'sendNew')).map(summarize); + const last = $(await getWriteTarget(request.user.userId, 'emails')); + const kept = sources.find((source) => source.connectionId === last?.connectionId); + return {sources, target: (kept ?? sources[0])?.connectionId ?? null}; + }); +} + +const writeSchema = z.object({ + connectionId: z.string().min(1), + to: z.array(z.email()).min(1).max(100), + subject: z.string().trim().max(1000).default(''), + body: z.string().max(100_000).default(''), + // Sent at once, or saved as a draft. + send: z.boolean(), +}); + +export type WrittenEmail = {sent: boolean}; + +/** `POST /api/widgets/emails`: an email from the mailbox picked, sent or saved as a draft. */ +export function handleWriteEmailRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const body = $(parseSchema(writeSchema, request.body)); + const orgId = $(await requireUserOrgId(request)); + const values = {to: body.to.join(', '), subject: body.subject, body: body.body}; + const write = body.send ? 'sendNew' : 'create'; + const source = $(await requireWritable(orgId, body.connectionId, 'emails', write, Object.keys(values))); + if (!body.send) { + $(await source.connector.create(source, values, null)); + } + if (body.send) { + const sendNew = $(requirePresent(source.connector.newCommands?.sendNew, ApiErr.forbidden())); + $(await sendNew(source, values)); + } + await afterWrite({orgId, userId: request.user.userId, source, action: write, title: body.subject, parent: null}); + return {sent: body.send}; + }); +} diff --git a/packages/api/src/widgets/files-route.test.ts b/packages/api/src/widgets/files-route.test.ts new file mode 100644 index 00000000..77a6a0d2 --- /dev/null +++ b/packages/api/src/widgets/files-route.test.ts @@ -0,0 +1,47 @@ +import {beforeEach, describe, expect, mock, test} from 'bun:test'; +import {Ok} from 'ts-results-es'; + +const listFiles = mock(); + +mock.module('../connections/google/drive-api', () => ({listFiles, driveQuoted: (term: string) => `'${term}'`})); +mock.module('../connections/google/google-client', () => ({callGoogle: (_connection: unknown, call: (token: string) => unknown) => call('token')})); +mock.module('./sources', () => ({ + findSources: async () => Ok([{connection: {id: 'google-1'}, integration: {id: 'google'}}]), + summarize: (source: {connection: {id: string}}) => ({connectionId: source.connection.id}), +})); +mock.module('../utils/user-org', () => ({requireUserOrgId: async () => Ok('org-1')})); +// As the Drive connector reads a file's type. +mock.module('../records/google-files', () => ({kindOf: (mimeType: string) => (mimeType.endsWith('spreadsheet') ? 'Spreadsheet' : mimeType.endsWith('document') ? 'Document' : 'File')})); + +const sheet = {id: 's1', name: 'Fragebogen Dr. Leichtle', mimeType: 'application/vnd.google-apps.spreadsheet', modifiedTime: '2026-10-12T00:00:00Z'}; +const memo = {id: 'd1', name: 'Kickoff notes', mimeType: 'application/vnd.google-apps.document', modifiedTime: '2026-10-01T00:00:00Z'}; + +function request(query: Record) { + return {user: {userId: 'user-1'}, query} as never; +} + +beforeEach(() => { + listFiles.mockReset(); +}); + +describe('handleFindFilesRoute', () => { + test('finds a spreadsheet asked for as a doc: the kind is no filter', async () => { + listFiles.mockResolvedValue(Ok({items: [sheet], nextPage: null})); + const {handleFindFilesRoute} = await import('./files'); + + const found = (await handleFindFilesRoute(request({words: 'leichtle', kind: 'Document'}))).unwrap(); + expect(listFiles).toHaveBeenCalledTimes(1); + expect(listFiles.mock.calls[0]?.[1]).toBe("name contains 'leichtle' and trashed = false"); + expect(found.files.map((file) => [file.name, file.kind, file.matched])).toEqual([['Fragebogen Dr. Leichtle', 'Spreadsheet', 'name']]); + }); + + test('searches inside the files where no name has the words', async () => { + listFiles.mockResolvedValueOnce(Ok({items: [], nextPage: null})).mockResolvedValueOnce(Ok({items: [memo], nextPage: null})); + const {handleFindFilesRoute} = await import('./files'); + + const found = (await handleFindFilesRoute(request({words: 'leichtle'}))).unwrap(); + expect(listFiles.mock.calls[1]?.[1]).toBe("fullText contains 'leichtle' and trashed = false"); + expect(listFiles.mock.calls[1]?.[2]).not.toHaveProperty('orderBy'); + expect(found.files.map((file) => [file.name, file.matched])).toEqual([['Kickoff notes', 'content']]); + }); +}); diff --git a/packages/api/src/widgets/files.test.ts b/packages/api/src/widgets/files.test.ts new file mode 100644 index 00000000..75984c76 --- /dev/null +++ b/packages/api/src/widgets/files.test.ts @@ -0,0 +1,46 @@ +import {describe, expect, mock, test} from 'bun:test'; + +import {gmailQuery} from '../records/email-search'; + +// The Google client and the mail connectors read the environment on import, which tests don't have. +mock.module('../utils/env', () => ({env: {NODE_ENV: 'test'}})); + +const {driveQuery, rankFiles} = await import('./files'); + +describe('the searches in each provider’s terms', () => { + test("Drive's: every word in the name, or in the text, the owner, never what is trashed, quotes escaped; the kind no filter", () => { + const search = {words: ['q3', "paul's"], kind: 'Presentation' as const, owner: 'paul@example.com'}; + expect(driveQuery(search, 'name')).toBe("name contains 'q3' and name contains 'paul\\'s' and 'paul@example.com' in owners and trashed = false"); + expect(driveQuery(search, 'content')).toBe("fullText contains 'q3' and fullText contains 'paul\\'s' and 'paul@example.com' in owners and trashed = false"); + expect(driveQuery({words: [], kind: null, owner: null}, 'name')).toBe('trashed = false'); + }); + + test('puts files of the kind said first, then those named so, then the most recently changed', () => { + const file = (id: string, kind: string, matched: 'name' | 'content', modified: string) => ({connectionId: 'g', id, name: id, kind, owner: '', modified, link: null, matched}); + const files = [ + file('new-sheet', 'Spreadsheet', 'name', '2026-10-12T00:00:00Z'), + file('old-doc', 'Document', 'name', '2026-01-01T00:00:00Z'), + file('mentions-it', 'Document', 'content', '2026-10-14T00:00:00Z'), + file('newer-sheet', 'Spreadsheet', 'name', '2026-10-13T00:00:00Z'), + ]; + expect(rankFiles(files, 'Document').map((one) => one.id)).toEqual(['old-doc', 'mentions-it', 'newer-sheet', 'new-sheet']); + expect(rankFiles(files, null).map((one) => one.id)).toEqual(['newer-sheet', 'new-sheet', 'old-doc', 'mentions-it']); + }); + + test("Gmail's: the sender, quoted where it has spaces, the words, and the day as Gmail writes it", () => { + expect(gmailQuery({from: 'Sam Lee', words: [['invoice'], ['q3 report']], since: '2026-10-07'})).toBe('from:"Sam Lee" invoice "q3 report" after:2026/10/07'); + expect( + gmailQuery({ + from: null, + words: [ + ['flight', 'itinerary', 'e-ticket'], + ['SFO', 'San Francisco'], + ], + since: null, + }), + ).toBe('(flight OR itinerary OR e-ticket) (SFO OR "San Francisco")'); + expect(gmailQuery({from: 'sam@example.com', words: [], since: null})).toBe('from:sam@example.com'); + expect(gmailQuery({from: null, words: [], since: null})).toBe(''); + expect(gmailQuery({from: null, involving: 'Jacky Doe', words: [], since: null})).toBe('{from:"Jacky Doe" to:"Jacky Doe" cc:"Jacky Doe"}'); + }); +}); diff --git a/packages/api/src/widgets/files.ts b/packages/api/src/widgets/files.ts new file mode 100644 index 00000000..9cf8bd42 --- /dev/null +++ b/packages/api/src/widgets/files.ts @@ -0,0 +1,119 @@ +import type {Result} from 'ts-results-es'; +import {z} from 'zod'; + +import {type ApiError, Do, parseSchema} from '@proxy/utils'; +import type {IntegrationId} from '@proxy/integrations'; + +import type {AuthenticatedRequest} from '../server/middleware/require-auth'; +import {requireUserOrgId} from '../utils/user-org'; +import {driveQuoted, listFiles, type DriveFile} from '../connections/google/drive-api'; +import {callGoogle} from '../connections/google/google-client'; +import {kindOf} from '../records/google-files'; +import {eachSource, type Failure} from './each-source'; +import {findSources, summarize, type Source, type SourceSummary} from './sources'; + +/** + * Files for the Find file widget: a search of every file store connected, each in its own terms, + * by name, or where no name has the words, inside the files. The kind said ("the leichtle doc") is + * a preference, not a filter: files of it come first, then the rest, the most recently changed + * first, since people say "doc" of a spreadsheet, a PDF or a Word file as well. + */ + +const KINDS = ['Document', 'Spreadsheet', 'Presentation', 'Folder'] as const; + +type Kind = (typeof KINDS)[number]; + +/** A search for files as the home page asks it: words of the name, the kind preferred, an owner's address. */ +export type FileSearch = {words: string[]; kind: Kind | null; owner: string | null}; + +/** Where a file has the words searched for: in its name, or only inside it. */ +export type Matched = 'name' | 'content'; + +export type FoundFile = {connectionId: string; id: string; name: string; kind: string; owner: string; modified: string; link: string | null; matched: Matched}; + +/** How an integration's files are searched; a file integration joins the widget with one. */ +type FilesSearch = (source: Source, search: FileSearch, limit: number) => Promise>; + +// What the widget lists at most, across every file store. +const LIMIT = 8; + +// How many files a name search reads, for those of the kind preferred to come first from among them. +const READ = 30; + +/** The search in Drive's query language: every word in the name, or in the text, owned by the owner. */ +export function driveQuery(search: FileSearch, matched: Matched): string { + const field = matched === 'name' ? 'name' : 'fullText'; + return [...search.words.map((word) => `${field} contains ${driveQuoted(word)}`), ...(search.owner ? [`${driveQuoted(search.owner)} in owners`] : []), 'trashed = false'].join(' and '); +} + +function foundFile(connectionId: string, file: DriveFile, matched: Matched): FoundFile { + const owner = file.owners?.[0]; + return { + connectionId, + id: file.id, + name: file.name, + kind: kindOf(file.mimeType), + owner: owner?.displayName ?? owner?.emailAddress ?? '', + modified: file.modifiedTime ?? '', + link: file.webViewLink ?? null, + matched, + }; +} + +// Drive, shared drives too: by name, the most recently changed first, else inside the files, in the +// order Drive finds best, as it sorts no full-text search. +const searchDrive: FilesSearch = (source, search, limit) => + callGoogle(source.connection, (token) => + Do(async ($) => { + const params = {corpora: 'allDrives'}; + const byName = $(await listFiles(token, driveQuery(search, 'name'), {...params, orderBy: 'modifiedTime desc', pageSize: String(READ)}, null)); + if (byName.items.length > 0 || search.words.length === 0) { + return byName.items.map((file) => foundFile(source.connection.id, file, 'name')); + } + const inside = $(await listFiles(token, driveQuery(search, 'content'), {...params, pageSize: String(limit)}, null)); + return inside.items.map((file) => foundFile(source.connection.id, file, 'content')); + }), + ); + +/** The files to show: of the kind preferred first, then those named with the words, then the most recently changed. */ +export function rankFiles(files: FoundFile[], kind: Kind | null): FoundFile[] { + const score = (file: FoundFile) => (file.kind === kind ? 2 : 0) + (file.matched === 'name' ? 1 : 0); + return [...files].sort((a, b) => score(b) - score(a) || Date.parse(b.modified || '0') - Date.parse(a.modified || '0')).slice(0, LIMIT); +} + +const SEARCHES: Partial> = {google: searchDrive}; + +const querySchema = z.object({ + words: z.union([z.string(), z.array(z.string())]).optional(), + kind: z.enum(KINDS).optional(), + owner: z.email().optional(), +}); + +export type FoundFiles = {sources: SourceSummary[]; files: FoundFile[]; failed: Failure[]}; + +/** `GET /api/widgets/files?words=…&kind=…&owner=…`: the files each file store finds. */ +export function handleFindFilesRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const query = $(parseSchema(querySchema, request.query)); + const words = query.words === undefined ? [] : Array.isArray(query.words) ? query.words : [query.words]; + const search: FileSearch = { + words: words + .map((word) => word.trim().slice(0, 100)) + .filter((word) => word !== '') + .slice(0, 10), + kind: query.kind ?? null, + owner: query.owner ?? null, + }; + const orgId = $(await requireUserOrgId(request)); + const sources = $(await findSources(orgId, 'files', 'list')).flatMap((source) => { + const find = SEARCHES[source.integration.id]; + return find ? [{...source, find}] : []; + }); + const {found, failed} = await eachSource(sources, (source) => source.find(source, search, LIMIT)); + const files = rankFiles( + found.flatMap(({value}) => value), + search.kind, + ); + return {sources: sources.map(summarize), files, failed}; + }); +} diff --git a/packages/api/src/widgets/google-calendars.test.ts b/packages/api/src/widgets/google-calendars.test.ts new file mode 100644 index 00000000..ba5a523a --- /dev/null +++ b/packages/api/src/widgets/google-calendars.test.ts @@ -0,0 +1,128 @@ +import {beforeEach, describe, expect, mock, test} from 'bun:test'; +import {Err, Ok} from 'ts-results-es'; + +import {ApiErr} from '@proxy/utils'; + +const listCalendars = mock(); +const listEvents = mock(); + +mock.module('../connections/google/google-client', () => ({callGoogle: (_connection: unknown, call: (token: string) => unknown) => call('token')})); +mock.module('../connections/google/calendar-api', () => ({listCalendars, listEvents})); + +beforeEach(() => { + mock.clearAllMocks(); +}); + +describe('googleCalendars.agenda', () => { + test('reads the calendars Google Calendar shows and their events, without the declined, Busy where only that is seen', async () => { + listCalendars.mockResolvedValue( + Ok([ + {id: 'me@work', summary: 'me@work', primary: true, accessRole: 'owner', backgroundColor: '#9fe1e7'}, + {id: 'team', summary: 'Team', selected: true, accessRole: 'writer'}, + {id: 'boss', summary: 'Boss', selected: true, accessRole: 'freeBusyReader'}, + {id: 'holidays', summary: 'Holidays', accessRole: 'reader'}, + {id: 'broken', summary: 'Broken', selected: true, accessRole: 'reader'}, + ]), + ); + listEvents.mockImplementation(async (_token: string, calendarId: string) => { + if (calendarId === 'broken') { + return Err(ApiErr.notFound('file')); + } + const events = { + 'me@work': [ + { + id: 'standup', + summary: 'Standup', + location: 'Room 1', + start: {dateTime: '2026-10-15T09:30:00+02:00'}, + end: {dateTime: '2026-10-15T09:45:00+02:00'}, + htmlLink: 'https://calendar.google.com/standup', + organizer: {email: 'me@work', self: true}, + attendees: [{email: 'me@work', self: true}, {email: 'sam@example.com'}, {email: 'room-1@resource.calendar.google.com', resource: true}], + }, + {id: 'declined', summary: 'Skip', start: {dateTime: '2026-10-15T11:00:00+02:00'}, end: {dateTime: '2026-10-15T12:00:00+02:00'}, attendees: [{self: true, responseStatus: 'declined'}]}, + ], + team: [{id: 'offsite', summary: 'Offsite', start: {date: '2026-10-15'}, end: {date: '2026-10-16'}, organizer: {email: 'boss@work'}}], + boss: [{id: 'x', start: {dateTime: '2026-10-15T14:00:00+02:00'}, end: {dateTime: '2026-10-15T15:00:00+02:00'}}], + }[calendarId]; + return Ok({events: events ?? [], nextPage: null}); + }); + const {googleCalendars} = await import('./google-calendars'); + + const range = {from: '2026-10-15T00:00:00+02:00', to: '2026-10-16T00:00:00+02:00'}; + const read = (await googleCalendars.agenda({connection: {id: 'work'}} as never, range)).unwrap(); + + expect(listEvents).not.toHaveBeenCalledWith('token', 'holidays', expect.anything()); + expect(listEvents).toHaveBeenCalledWith('token', 'me@work', {...range, search: null, page: null}); + expect(read.calendars.map((calendar) => [calendar.id, calendar.primary, calendar.writable])).toEqual([ + ['me@work', true, true], + ['team', false, true], + ['boss', false, false], + ['broken', false, false], + ]); + // Only an event the account organizes, in a calendar it writes to, can be changed. + expect(read.events.map((event) => [event.title, event.allDay, event.editable])).toEqual([ + ['Standup', false, true], + ['Offsite', true, false], + ['Busy', false, false], + ]); + // The guests but the account and the room. + expect(read.events[0]).toMatchObject({location: 'Room 1', guests: ['sam@example.com']}); + expect(read.events[0]?.link).toBe('https://calendar.google.com/standup'); + }); +}); + +describe('googleCalendars.search', () => { + test('searches every calendar shown for each search from the time on, but those only seen as busy, each event once, soonest first', async () => { + listCalendars.mockResolvedValue( + Ok([ + {id: 'me@work', summary: 'me@work', primary: true, accessRole: 'owner'}, + {id: 'team', summary: 'Team', selected: true, accessRole: 'writer'}, + {id: 'boss', summary: 'Boss', selected: true, accessRole: 'freeBusyReader'}, + ]), + ); + const oneOnOne = {id: '1on1_20261021', summary: 'Arne / Konsti', start: {dateTime: '2026-10-21T15:00:00+02:00'}, end: {dateTime: '2026-10-21T15:30:00+02:00'}}; + const lunch = {id: 'lunch', summary: 'Lunch', start: {dateTime: '2026-10-17T12:00:00+02:00'}, end: {dateTime: '2026-10-17T13:00:00+02:00'}}; + listEvents.mockImplementation(async (_token: string, calendarId: string, query: {search: string}) => { + if (calendarId === 'me@work') { + return Ok({events: query.search === 'arne' ? [oneOnOne] : [oneOnOne, lunch], nextPage: null}); + } + return Ok({events: [], nextPage: null}); + }); + const {googleCalendars} = await import('./google-calendars'); + + const from = '2026-10-16T00:00:00+02:00'; + const found = (await googleCalendars.search({connection: {id: 'work'}} as never, {from, searches: ['arne', 'arne@example.com']})).unwrap(); + + expect(listEvents).toHaveBeenCalledTimes(4); + expect(listEvents).toHaveBeenCalledWith('token', 'team', {from, search: 'arne@example.com', page: null}); + expect(listEvents).not.toHaveBeenCalledWith('token', 'boss', expect.anything()); + expect(found.map((event) => event.title)).toEqual(['Lunch', 'Arne / Konsti']); + }); +}); + +describe('googleCalendars, an event on two calendars', () => { + test('shows it once, the copy the person can change, and fails the account when Google is out of reach', async () => { + listCalendars.mockResolvedValue( + Ok([ + {id: 'colleague', summary: 'Arne', selected: true, accessRole: 'reader'}, + {id: 'me@work', summary: 'me@work', primary: true, accessRole: 'owner'}, + ]), + ); + const oneOnOne = {id: '1on1', summary: 'Arne / me', start: {dateTime: '2026-10-21T15:00:00+02:00'}, end: {dateTime: '2026-10-21T15:30:00+02:00'}, organizer: {email: 'me@work', self: true}}; + listEvents.mockResolvedValue(Ok({events: [oneOnOne], nextPage: null})); + const {googleCalendars} = await import('./google-calendars'); + const source = {connection: {id: 'work'}} as never; + + const range = {from: '2026-10-21T00:00:00+02:00', to: '2026-10-22T00:00:00+02:00'}; + const day = (await googleCalendars.agenda(source, range)).unwrap(); + expect(day.events.map((event) => [event.calendarId, event.editable])).toEqual([['me@work', true]]); + const found = (await googleCalendars.search(source, {from: range.from, searches: ['arne']})).unwrap(); + expect(found.map((event) => [event.calendarId, event.editable])).toEqual([['me@work', true]]); + + listEvents.mockImplementation(async (_token: string, calendarId: string) => + calendarId === 'colleague' ? Err(ApiErr.providerUnreachable(new Error('rateLimitExceeded'))) : Ok({events: [oneOnOne], nextPage: null}), + ); + expect((await googleCalendars.search(source, {from: range.from, searches: ['arne']})).isErr()).toBe(true); + }); +}); diff --git a/packages/api/src/widgets/google-calendars.ts b/packages/api/src/widgets/google-calendars.ts new file mode 100644 index 00000000..957a14cd --- /dev/null +++ b/packages/api/src/widgets/google-calendars.ts @@ -0,0 +1,142 @@ +import {Err, Ok, type Result} from 'ts-results-es'; + +import {type ApiError, Do, mapWithLimit} from '@proxy/utils'; + +import {listCalendars, listEvents, type EventsQuery, type GoogleCalendar, type GoogleEvent} from '../connections/google/calendar-api'; +import {callGoogle} from '../connections/google/google-client'; +import {log} from '../observability/log'; +import type {AgendaCalendar, AgendaEvent, CalendarAdapter} from './agenda'; + +/** + * Google Calendar for the widgets: the calendars Google Calendar shows, ticked in its list, and + * their events in a range, or from a time on those its search finds, each time a repeating event + * comes round its own. Events the account declined are left out, as Google Calendar fades them; a + * calendar it only sees as free or busy shows its events as Busy, and isn't searched, having + * nothing to search. One calendar Google won't list the events of is left out too, rather than + * the account's whole day; Google out of reach or busy fails the account, rather than leaving out + * what it would have found. + */ + +// A person ticks a dozen calendars at most; past that, the rest wait on the first. +const MAX_CALENDARS = 12; + +function writable(calendar: GoogleCalendar): boolean { + return calendar.accessRole === 'owner' || calendar.accessRole === 'writer'; +} + +function calendarOf(connectionId: string, calendar: GoogleCalendar): AgendaCalendar { + return { + connectionId, + id: calendar.id, + name: calendar.summaryOverride ?? calendar.summary ?? calendar.id, + primary: calendar.primary === true, + writable: writable(calendar), + color: calendar.backgroundColor ?? null, + }; +} + +function declined(event: GoogleEvent): boolean { + return event.attendees?.some((attendee) => attendee.self && attendee.responseStatus === 'declined') === true; +} + +function eventOf(connectionId: string, calendar: GoogleCalendar, event: GoogleEvent): AgendaEvent[] { + const start = event.start?.dateTime ?? event.start?.date; + const end = event.end?.dateTime ?? event.end?.date; + if (!start || !end || event.status === 'cancelled' || declined(event)) { + return []; + } + const title = event.summary ?? (calendar.accessRole === 'freeBusyReader' ? 'Busy' : '(No title)'); + const guests = (event.attendees ?? []).flatMap((attendee) => (attendee.self || attendee.resource || !attendee.email ? [] : [attendee.email])); + const editable = writable(calendar) && (event.organizer === undefined || event.organizer.self === true); + return [ + { + connectionId, + calendarId: calendar.id, + id: event.id, + title, + start, + end, + allDay: event.start?.date !== undefined, + location: event.location ?? '', + guests, + editable, + link: event.htmlLink ?? null, + }, + ]; +} + +// The calendars Google Calendar shows: the primary one and those ticked in its list. +async function shownCalendars(token: string): Promise> { + return (await listCalendars(token)).map((calendars) => calendars.filter((calendar) => calendar.primary === true || calendar.selected === true).slice(0, MAX_CALENDARS)); +} + +type Asked = {calendar: GoogleCalendar; query: EventsQuery}; + +// Calendars asked at once: a dozen calendars, each searched three ways, is a burst Google turns down. +const ASKED_AT_ONCE = 6; + +// Google out of reach, busy or turning the sign-in down fails the account, as a day or a search +// missing what it would have found isn't to be shown as all there is; a calendar Google refuses to +// list is left out. +function failsAccount(error: ApiError): boolean { + return error.kind === 'provider_unreachable' || error.kind === 'credentials_rejected'; +} + +// The events each ask answers, those of a calendar Google wouldn't list left out. +async function eventsOf(token: string, connectionId: string, asks: Asked[]): Promise> { + const answers = await mapWithLimit(asks, ASKED_AT_ONCE, ({calendar, query}) => listEvents(token, calendar.id, query)); + const failure = answers.find((answer) => answer.isErr() && failsAccount(answer.error)); + if (failure?.isErr()) { + return Err(failure.error); + } + return Ok( + asks.flatMap(({calendar}, index) => { + const answer = answers[index]; + if (answer?.isOk()) { + return answer.value.events.flatMap((event) => eventOf(connectionId, calendar, event)); + } + log.warn('A Google calendar’s events could not be listed', {connectionId, kind: answer?.isErr() ? answer.error.kind : null}); + return []; + }), + ); +} + +// Each event once, though two searches, or two calendars it is on, found it: the copy the person +// can change where one is, as on their own calendar beside a colleague's they only read. +function eachOnce(events: AgendaEvent[]): AgendaEvent[] { + const kept = new Map(); + events.forEach((event) => { + const key = `${event.id} ${event.start}`; + const had = kept.get(key); + if (!had || (event.editable && !had.editable)) { + kept.set(key, event); + } + }); + return [...kept.values()]; +} + +export const googleCalendars: CalendarAdapter = { + agenda: (source, range) => + callGoogle(source.connection, (token) => + Do(async ($) => { + const connectionId = source.connection.id; + const shown = $(await shownCalendars(token)); + const events = $( + await eventsOf( + token, + connectionId, + shown.map((calendar) => ({calendar, query: {from: range.from, to: range.to, search: null, page: null}})), + ), + ); + return {calendars: shown.map((calendar) => calendarOf(connectionId, calendar)), events: eachOnce(events)}; + }), + ), + search: (source, {from, searches}) => + callGoogle(source.connection, (token) => + Do(async ($) => { + const searched = $(await shownCalendars(token)).filter((calendar) => calendar.accessRole !== 'freeBusyReader'); + const asks = searched.flatMap((calendar) => searches.map((search) => ({calendar, query: {from, search, page: null}}))); + return eachOnce($(await eventsOf(token, source.connection.id, asks))).sort((a, b) => Date.parse(a.start) - Date.parse(b.start)); + }), + ), +}; diff --git a/packages/api/src/widgets/mail-searches.ts b/packages/api/src/widgets/mail-searches.ts new file mode 100644 index 00000000..d67c9f30 --- /dev/null +++ b/packages/api/src/widgets/mail-searches.ts @@ -0,0 +1,28 @@ +import type {Result} from 'ts-results-es'; + +import type {ApiError} from '@proxy/utils'; +import type {IntegrationId} from '@proxy/integrations'; + +import type {DataRecord} from '../records/connector'; +import {findInboxEmails} from '../records/email-connector'; +import type {EmailSearch} from '../records/email-search'; +import {findGmailEmails} from '../records/google-emails'; +import type {Source} from './sources'; + +/** + * How an integration's mail is searched; a mail integration joins the widgets with one. It runs + * several searches at once, as the People widget asks one per name, over one sign-in, and answers + * the emails each found, in the order asked, and stops when `signal` says it was given up on. Its records have a list's values and `cc` beside + * them, which the collection has no field for but the widgets find people in. + */ +export type MailSearch = (source: Source, searches: EmailSearch[], limit: number, signal: AbortSignal) => Promise>; + +const SEARCHES: Partial> = {google: findGmailEmails, email: findInboxEmails}; + +/** The mailboxes among `sources` that can be searched, each with its search. */ +export function searchable(sources: Source[]): Array { + return sources.flatMap((source) => { + const find = SEARCHES[source.integration.id]; + return find ? [{...source, find}] : []; + }); +} diff --git a/packages/api/src/widgets/new-event.ts b/packages/api/src/widgets/new-event.ts new file mode 100644 index 00000000..bc44f7b2 --- /dev/null +++ b/packages/api/src/widgets/new-event.ts @@ -0,0 +1,40 @@ +import type {Result} from 'ts-results-es'; +import {z} from 'zod'; + +import {type ApiError, Do, parseSchema} from '@proxy/utils'; + +import type {AuthenticatedRequest} from '../server/middleware/require-auth'; +import {requireUserOrgId} from '../utils/user-org'; +import {requireWritable} from './sources'; +import {afterWrite} from './writes'; + +/** + * The New event widget's Create: an event in the calendar picked, made as an agent's would be, + * through the calendar's connector, so the guests get the provider's invitation. + */ + +const bodySchema = z.object({ + connectionId: z.string().min(1), + calendarId: z.string().min(1), + title: z.string().trim().min(1).max(500), + start: z.iso.datetime({offset: true}), + end: z.iso.datetime({offset: true}), + location: z.string().trim().max(500).default(''), + guests: z.array(z.email()).max(100).default([]), +}); + +export type MadeEvent = {title: string; link: string | null}; + +/** `POST /api/widgets/events`. */ +export function handleCreateEventRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const body = $(parseSchema(bodySchema, request.body)); + const orgId = $(await requireUserOrgId(request)); + const values = {title: body.title, start: body.start, end: body.end, location: body.location, guests: body.guests.join(', ')}; + const written = Object.keys(values).filter((key) => key !== 'guests' || body.guests.length > 0); + const source = $(await requireWritable(orgId, body.connectionId, 'events', 'create', written)); + const made = $(await source.connector.create(source, values, body.calendarId)); + await afterWrite({orgId, userId: request.user.userId, source, action: 'create', title: made.values.title ?? body.title, parent: body.calendarId}); + return {title: made.values.title ?? body.title, link: made.url ?? null}; + }); +} diff --git a/packages/api/src/widgets/people.test.ts b/packages/api/src/widgets/people.test.ts new file mode 100644 index 00000000..641e0953 --- /dev/null +++ b/packages/api/src/widgets/people.test.ts @@ -0,0 +1,170 @@ +import {beforeEach, describe, expect, mock, test} from 'bun:test'; +import {Err, Ok} from 'ts-results-es'; + +import {ApiErr} from '@proxy/utils'; + +const findSources = mock(); +const requireUserOrgId = mock(async () => Ok('org-1')); + +mock.module('./sources', () => ({findSources, summarize: (source: {connection: {id: string}}) => ({connectionId: source.connection.id})})); +// Every mailbox can be searched, with the search each fake mailbox has. +mock.module('./mail-searches', () => ({searchable: (sources: Array<{search: unknown}>) => sources.map((source) => ({...source, find: source.search}))})); +mock.module('../utils/user-org', () => ({requireUserOrgId})); + +function contact(id: string, name: string, emails: string) { + return { + id, + values: {name, emails, phones: '+1 555 0100', company: 'Example', jobTitle: '', addresses: 'One Boulevard Plaza\nSecond Street'}, + updatedAt: '', + url: `https://contacts.google.com/person/${id}`, + }; +} + +function source(id: string, list: (search: string) => unknown) { + return {connection: {id, account: `${id}@example.com`, integrationId: 'google'}, connector: {list: mock(async (_target: unknown, query: {search: string}) => list(query.search))}}; +} + +beforeEach(() => { + mock.clearAllMocks(); +}); + +describe('rankPeople', () => { + test('puts the whole name first, then its beginning, then a word beginning with it, and each person once', async () => { + const {personOf, rankPeople} = await import('./people'); + const people = [ + personOf('a', contact('c1', 'Sam Jacky', 'sam@example.com')), + personOf('a', contact('c2', 'Jacky Doe', 'jacky@example.com')), + personOf('b', contact('c3', 'Jacky Doe', 'JACKY@example.com')), + personOf('a', contact('c4', 'Jacky', 'j@example.com')), + ]; + expect(rankPeople(people, 'jacky').map((person) => [person.name, person.connectionId])).toEqual([ + ['Jacky', 'a'], + ['Jacky Doe', 'a'], + ['Sam Jacky', 'a'], + ]); + }); + + test('reads a contact by the fields every contact book shares', async () => { + const {personOf} = await import('./people'); + expect(personOf('a', contact('c1', 'Sam Lee', 'sam@example.com, s@home.example'))).toEqual({ + origin: 'contacts', + connectionId: 'a', + id: 'c1', + name: 'Sam Lee', + emails: ['sam@example.com', 's@home.example'], + phones: ['+1 555 0100'], + company: 'Example', + jobTitle: '', + addresses: ['One Boulevard Plaza', 'Second Street'], + link: 'https://contacts.google.com/person/c1', + }); + }); +}); + +// An email as a mailbox search finds it: its sender, recipients and those copied, names where they were written. +function email(from: string, to: string, cc = '') { + return {id: from, values: {from, to, cc, subject: 'Hi'}, updatedAt: ''}; +} + +describe('peopleInEmails', () => { + test('takes the people close to the name from the emails, the most written first, never the account itself', async () => { + const {peopleInEmails} = await import('./people'); + const emails = [ + email('Jacky Doe ', 'Me , Sam Lee '), + email('Me ', 'jacky.chen@example.com, jacky@example.com'), + email('Me ', 'Jacky Chen , Jacky Doe '), + email('jacky@example.com', 'me@example.com'), + ]; + expect(peopleInEmails('mail', 'ME@example.com', emails, 'jacky').map((person) => [person.name, person.emails, person.origin])).toEqual([ + ['Jacky Doe', ['jacky@example.com'], 'email'], + ['Jacky Chen', ['jacky.chen@example.com'], 'email'], + ]); + }); + + test('finds someone only ever copied', async () => { + const {peopleInEmails} = await import('./people'); + const emails = [email('Sam Lee ', 'me@example.com', 'Jacky Doe , Me ')]; + expect(peopleInEmails('mail', 'me@example.com', emails, 'jacky').map((person) => [person.name, person.emails])).toEqual([['Jacky Doe', ['jacky@example.com']]]); + }); +}); + +describe('rankPeople with email', () => { + test('a contact before someone only in email, who is the contact where the address is the same', async () => { + const {personOf, peopleInEmails, rankPeople} = await import('./people'); + const inEmail = peopleInEmails('mail', 'me@example.com', [email('Jacky Doe ', 'me@example.com'), email('Jacky Roe ', 'me@example.com')], 'jacky'); + const saved = personOf('book', contact('c1', 'Jacky Doe', 'jacky@example.com')); + expect(rankPeople([...inEmail, saved], 'jacky').map((person) => [person.name, person.origin])).toEqual([ + ['Jacky Doe', 'contacts'], + ['Jacky Roe', 'email'], + ]); + }); +}); + +describe('mergePeople', () => { + test('one person for the same first and last name, or the same address, the contact leading with every address', async () => { + const {mergePeople, personOf, peopleInEmails} = await import('./people'); + const inEmail = peopleInEmails( + 'mail', + 'me@example.com', + [ + email('Arne Strickmann ', 'me@example.com'), + email('Arne Strickmann ', 'me@example.com'), + email('Arne Strickmann ', 'me@example.com'), + email('Arne Elmar Strickmann ', 'me@example.com'), + email('Arne ', 'me@example.com'), + email('Arne Strickmann ', 'me@example.com'), + email('Arne Strickmann ', 'me@example.com'), + ], + 'arne', + ); + expect(mergePeople(inEmail).map((person) => [person.name, person.emails])).toEqual([ + ['Arne Strickmann', ['arne@varislabs.com', 'arne.s@gmail.com', 'arne.strickmann@uni.example']], + ['Arne', ['arne@other.example']], + ]); + + const saved = personOf('book', contact('c1', 'Arne Strickmann', 'arne.s@gmail.com')); + const [first] = mergePeople([...inEmail, saved]); + expect(first).toMatchObject({origin: 'contacts', id: 'c1', name: 'Arne Strickmann', phones: ['+1 555 0100']}); + expect(first?.emails).toEqual(['arne.s@gmail.com', 'arne@varislabs.com', 'arne.strickmann@uni.example']); + }); +}); + +describe('handlePeopleRoute', () => { + test('searches every contact book for each name, and says which it could not', async () => { + const mailSearch = mock(async (_source: unknown, searches: Array<{involving: string}>) => + Ok(searches.map((search) => (search.involving === 'hugo' ? [email('Hugo Brandt ', 'mail@example.com')] : []))), + ); + findSources.mockImplementation(async (_orgId: string, collectionId: string) => + Ok( + collectionId === 'contacts' + ? [ + source('work', (search) => Ok({records: search === 'jacky' ? [contact('c1', 'Jacky Doe', 'jacky@example.com')] : [], nextPage: null})), + source('home', () => Err(ApiErr.credentialsRejected())), + ] + : [{...source('mail', () => Ok({records: [], nextPage: null})), search: mailSearch}], + ), + ); + const {handlePeopleRoute} = await import('./people'); + + const answer = (await handlePeopleRoute({user: {userId: 'user-1'}, query: {name: ['jacky', ' hugo ', 'jacky']}} as never)).unwrap(); + expect(findSources).toHaveBeenCalledWith('org-1', 'contacts', 'list'); + expect(findSources).toHaveBeenCalledWith('org-1', 'emails', 'list'); + // The mailbox is asked once, for every name. + expect(mailSearch).toHaveBeenCalledTimes(1); + expect(mailSearch).toHaveBeenCalledWith( + expect.anything(), + [ + {from: null, involving: 'jacky', words: [], since: null}, + {from: null, involving: 'hugo', words: [], since: null}, + ], + 10, + expect.any(AbortSignal), + ); + expect(answer.sources.map((one) => one.connectionId)).toEqual(['work', 'home', 'mail']); + expect(answer.people.map(({name, matches}) => [name, matches.map((match) => `${match.name} (${match.origin})`)])).toEqual([ + ['jacky', ['Jacky Doe (contacts)']], + ['hugo', ['Hugo Brandt (email)']], + ]); + expect(answer.failed).toEqual([{connectionId: 'home', account: 'home@example.com', message: 'its sign-in has run out: connect it again'}]); + }); +}); diff --git a/packages/api/src/widgets/people.ts b/packages/api/src/widgets/people.ts new file mode 100644 index 00000000..892d5aaa --- /dev/null +++ b/packages/api/src/widgets/people.ts @@ -0,0 +1,226 @@ +import addressparser from 'nodemailer/lib/addressparser'; +import type {Result} from 'ts-results-es'; +import {z} from 'zod'; + +import {type ApiError, Do, parseSchema} from '@proxy/utils'; + +import type {AuthenticatedRequest} from '../server/middleware/require-auth'; +import {requireUserOrgId} from '../utils/user-org'; +import type {DataRecord} from '../records/connector'; +import {eachSource, type Failure} from './each-source'; +import {searchable} from './mail-searches'; +import {findSources, summarize, type Source, type SourceSummary} from './sources'; + +/** + * People by name, for the widgets that name someone: the contact itself, the guests of an event, + * the recipients of an email. Every contact book connected is searched, as its provider searches + * it, and every mailbox for the people its email is from, to or copied to, since many a person + * written to was never saved as a contact. What they find is one list per name: the closest names + * first, contacts before people only in email, each person once. + */ + +/** + * A person: a contact, read from the `contacts` collection's fields, which every contact book + * shares, or someone in a mailbox's email, known by their name and address alone. + */ +export type Person = { + // Where they were found: a contact book, or the email of a mailbox. + origin: 'contacts' | 'email'; + connectionId: string; + id: string; + name: string; + emails: string[]; + phones: string[]; + company: string; + jobTitle: string; + addresses: string[]; + link: string | null; +}; + +export type PeopleAnswer = {sources: SourceSummary[]; people: Array<{name: string; matches: Person[]}>; failed: Failure[]}; + +// The people a name finds at most, the closest first. +const MATCHES = 5; + +// The newest emails involving a name read for the people in them, in each mailbox. +const EMAILS_READ = 10; + +const querySchema = z.object({ + name: z.union([z.string(), z.array(z.string())]).transform((names) => (Array.isArray(names) ? names : [names]).map((name) => name.trim().slice(0, 100)).filter((name) => name !== '')), +}); + +function split(text: string | undefined, separator: RegExp): string[] { + return (text ?? '') + .split(separator) + .map((part) => part.trim()) + .filter((part) => part !== ''); +} + +export function personOf(connectionId: string, record: DataRecord): Person { + const {values} = record; + return { + origin: 'contacts', + connectionId, + id: record.id, + name: values.name ?? '', + emails: split(values.emails, /,/), + phones: split(values.phones, /,/), + company: values.company ?? '', + jobTitle: values.jobTitle ?? '', + addresses: split(values.addresses, /\n/), + link: record.url ?? null, + }; +} + +// How closely a contact's name is the one written: all of it, its beginning, or one of its words +// beginning with the first word written ("Jacky" for Jacky Doe); a contact the provider found by +// its address or company comes last. +function closeness(person: Person, name: string): number { + const wanted = name.toLowerCase(); + const full = person.name.toLowerCase(); + if (full === wanted) { + return 3; + } + if (full.startsWith(wanted)) { + return 2; + } + const [first = ''] = wanted.split(/\s+/); + if (full.split(/\s+/).some((word) => word.startsWith(first))) { + return 1; + } + return 0; +} + +// An address mail is sent from by a machine, which nobody writes back to: Google Drive's sharing +// notices come from drive-shares-dm-noreply@google.com under the sharer's name. +const AUTOMATED = /(^|[-_.+])(no-?reply|do-?not-?reply|notifications?|mailer-daemon|bounces?)([-_.+]|@)/; + +function automated(address: string): boolean { + return AUTOMATED.test(address); +} + +/** + * The people in a mailbox's emails, from, to or copied, whose names, or addresses where they have + * none, are close to the one written, those in the most emails first, the mailbox's own account + * left out. Gmail and IMAP find an email by anyone in it, so the rest of those in it are someone else. + */ +export function peopleInEmails(connectionId: string, account: string, records: DataRecord[], name: string): Person[] { + const found = new Map(); + for (const record of records) { + const {from = '', to = '', cc = ''} = record.values; + const addresses = addressparser(`${from}, ${to}, ${cc}`, {flatten: true}); + for (const {name: written, address} of addresses) { + const key = address?.toLowerCase(); + if (!key || key === account.toLowerCase() || automated(key)) { + continue; + } + const known = found.get(key); + found.set(key, {name: known?.name || written.trim(), count: (known?.count ?? 0) + 1}); + } + } + return [...found.entries()] + .sort(([, a], [, b]) => b.count - a.count) + .map(([address, seen]): Person => ({origin: 'email', connectionId, id: address, name: seen.name || address, emails: [address], phones: [], company: '', jobTitle: '', addresses: [], link: null})) + .filter((person) => closeness(person, name) > 0); +} + +// A name as two people's are compared: its first and last word, without case or accents, so +// "Arne Elmar Strickmann" is "Arne Strickmann". None for one word or an address, which say too +// little to be sure. +function nameKey(person: Person): string | null { + if (person.name.includes('@')) { + return null; + } + const words = person.name + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .split(/[\s,.]+/) + .filter((word) => word !== ''); + const [first] = words; + const last = words.at(-1); + return first && last && words.length > 1 ? `${first} ${last}` : null; +} + +function uniqueAddresses(addresses: string[]): string[] { + const seen = new Set(); + return addresses.filter((address) => { + const key = address.toLowerCase(); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +/** + * Everyone found more than once as one person: the same address in two contact books or + * mailboxes, or the same first and last name, as someone writing from work and from home is. A + * contact leads, saying more; else the first found, whose address is the one most written. Their + * addresses, numbers and postal addresses are all kept, the leader's first. + */ +export function mergePeople(people: Person[]): Person[] { + const merged: Person[] = []; + for (const person of people) { + const key = nameKey(person); + const addresses = person.emails.map((email) => email.toLowerCase()); + const same = merged.findIndex((one) => (key !== null && nameKey(one) === key) || one.emails.some((email) => addresses.includes(email.toLowerCase()))); + const kept = merged[same]; + if (!kept) { + merged.push(person); + continue; + } + const [lead, other] = kept.origin === 'email' && person.origin === 'contacts' ? [person, kept] : [kept, person]; + merged[same] = { + ...lead, + emails: uniqueAddresses([...lead.emails, ...other.emails]), + phones: [...new Set([...lead.phones, ...other.phones])], + addresses: [...new Set([...lead.addresses, ...other.addresses])], + }; + } + return merged; +} + +/** The closest people first, a contact before someone only in email, each once, at most five. */ +export function rankPeople(people: Person[], name: string): Person[] { + return mergePeople(people) + .map((person, index) => ({person, index, score: closeness(person, name)})) + .sort((a, b) => b.score - a.score || Number(a.person.origin === 'email') - Number(b.person.origin === 'email') || a.index - b.index) + .map(({person}) => person) + .slice(0, MATCHES); +} + +// The people a name finds in one contact book, as its provider searches it. +function searchContacts(source: Source, name: string): Promise> { + return source.connector.list(source, {search: name, page: null, filter: null, parent: null}).then((page) => page.map(({records}) => records.map((record) => personOf(source.connection.id, record)))); +} + +/** `GET /api/widgets/people?name=…&name=…`: the people each name finds in every contact book and mailbox. */ +export function handlePeopleRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const {name: names} = $(parseSchema(querySchema, request.query)); + const orgId = $(await requireUserOrgId(request)); + const books = $(await findSources(orgId, 'contacts', 'list')); + const mailboxes = searchable($(await findSources(orgId, 'emails', 'list'))); + const wanted = [...new Set(names)].slice(0, 10); + // Each mailbox is asked once for every name, over one sign-in, rather than once a name. + const searches = wanted.map((name) => ({from: null, involving: name, words: [], since: null})); + const [inBooks, inEmail] = await Promise.all([ + Promise.all(wanted.map((name) => eachSource(books, (source) => searchContacts(source, name)))), + eachSource(mailboxes, (source, signal) => source.find(source, searches, EMAILS_READ, signal)), + ]); + const people = wanted.map((name, index) => [ + ...(inBooks[index]?.found ?? []).flatMap(({value}) => value), + ...inEmail.found.flatMap(({source, value}) => peopleInEmails(source.connection.id, source.connection.account, value[index] ?? [], name)), + ]); + const failures = [...inBooks.flatMap((one) => one.failed), ...inEmail.failed]; + const failed = new Map(failures.map((failure): [string, Failure] => [failure.connectionId, failure])); + const sources = new Map([...books, ...mailboxes].map((source): [string, SourceSummary] => [source.connection.id, summarize(source)])); + return { + sources: [...sources.values()], + people: wanted.map((name, index) => ({name, matches: rankPeople(people[index] ?? [], name)})), + failed: [...failed.values()], + }; + }); +} diff --git a/packages/api/src/widgets/rates.test.ts b/packages/api/src/widgets/rates.test.ts new file mode 100644 index 00000000..41b522be --- /dev/null +++ b/packages/api/src/widgets/rates.test.ts @@ -0,0 +1,14 @@ +import {describe, expect, spyOn, test} from 'bun:test'; + +const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((async () => + Response.json({amount: 1, base: 'EUR', date: '2026-10-14', rates: {USD: 1.1195, GBP: 0.84385}})) as unknown as typeof fetch); + +describe('handleRatesRoute', () => { + test("answers the bank's rates of the day, the euro's own among them, asked for once an hour", async () => { + const {handleRatesRoute} = await import('./rates'); + + expect((await handleRatesRoute()).unwrap()).toEqual({date: '2026-10-14', rates: {EUR: 1, USD: 1.1195, GBP: 0.84385}}); + await handleRatesRoute(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/widgets/rates.ts b/packages/api/src/widgets/rates.ts new file mode 100644 index 00000000..7131c1fc --- /dev/null +++ b/packages/api/src/widgets/rates.ts @@ -0,0 +1,36 @@ +import {Err, Ok, type Result} from 'ts-results-es'; +import {z} from 'zod'; + +import {ApiErr, type ApiError, httpRequest} from '@proxy/utils'; +import type {Rates} from '@proxy/utils/units'; + +/** + * The day's exchange rates for Convert units: the European Central Bank's, from Frankfurter, which + * takes no key and is asked nothing of anyone's. The bank publishes once a working day, so the + * rates are kept an hour, for every person alike. + */ + +const RATES_URL = 'https://api.frankfurter.dev/v1/latest'; + +const KEEP_MS = 3_600_000; + +/** What one euro is worth in each currency, and the day the bank published it. */ +export type DayRates = {date: string; rates: Rates}; + +const answerSchema = z.object({base: z.literal('EUR'), date: z.string(), rates: z.record(z.string(), z.number())}); + +let kept: {at: number; rates: DayRates} | null = null; + +/** `GET /api/widgets/rates`. */ +export async function handleRatesRoute(): Promise> { + if (kept && Date.now() - kept.at < KEEP_MS) { + return Ok(kept.rates); + } + const answered = await httpRequest(RATES_URL, {method: 'GET'}, {schema: answerSchema, timeoutMs: 5000}); + if (answered.isErr()) { + return Err(ApiErr.providerUnreachable(answered.error)); + } + const rates = {date: answered.value.date, rates: {EUR: 1, ...answered.value.rates}}; + kept = {at: Date.now(), rates}; + return Ok(rates); +} diff --git a/packages/api/src/widgets/sources.test.ts b/packages/api/src/widgets/sources.test.ts new file mode 100644 index 00000000..4fa2b9a2 --- /dev/null +++ b/packages/api/src/widgets/sources.test.ts @@ -0,0 +1,73 @@ +import {beforeEach, describe, expect, mock, test} from 'bun:test'; +import {Ok} from 'ts-results-es'; + +const listConnectionsWithCredential = mock(); +const getConnectionWithCredential = mock(); +const unavailableActions = mock((_connection: {id: string}): string[] => []); + +// The Google client and the mail connectors read the environment on import, which tests don't have. +mock.module('../utils/env', () => ({env: {NODE_ENV: 'test'}})); +mock.module('@proxy/db/connection', () => ({listConnectionsWithCredential, getConnectionWithCredential, getConnectionCredential: mock(), updateConnectionCredential: mock()})); +mock.module('../connections/unavailable', () => ({ + unavailableActions, + unavailableError: (_integration: unknown, actionId: string) => ({kind: 'validation_error', statusCode: 400, message: `not allowed: ${actionId}`}), +})); + +function connection(id: string, integrationId: string) { + return {id, integrationId, account: `${id}@example.com`, createdAt: new Date(0), tools: null, name: null, defaults: [], credential: 'sealed'}; +} + +beforeEach(() => { + mock.clearAllMocks(); + unavailableActions.mockImplementation(() => []); +}); + +describe('findSources', () => { + test('finds every connection with the collection whose sign-in covered reading it', async () => { + listConnectionsWithCredential.mockResolvedValue( + Ok([connection('info', 'info'), connection('work', 'google'), connection('home', 'google'), connection('imap', 'email'), connection('narrow', 'google')]), + ); + unavailableActions.mockImplementation((stored) => (stored.id === 'narrow' ? ['readEvents', 'writeEvents'] : [])); + const {findSources} = await import('./sources'); + + const calendars = (await findSources('org-1', 'events', 'list')).unwrap(); + expect(calendars.map((source) => source.connection.id)).toEqual(['work', 'home']); + + const mailboxes = (await findSources('org-1', 'emails', 'list')).unwrap(); + expect(mailboxes.map((source) => [source.connection.id, source.integration.id])).toEqual([ + ['work', 'google'], + ['home', 'google'], + ['imap', 'email'], + ['narrow', 'google'], + ]); + }); + + test('asks for a command by its id: sending a new email', async () => { + listConnectionsWithCredential.mockResolvedValue(Ok([connection('work', 'google'), connection('readonly', 'google')])); + unavailableActions.mockImplementation((stored) => (stored.id === 'readonly' ? ['sendEmails'] : [])); + const {findSources} = await import('./sources'); + + expect((await findSources('org-1', 'emails', 'sendNew')).unwrap().map((source) => source.connection.id)).toEqual(['work']); + }); +}); + +describe('requireWritable', () => { + test("refuses guests where the sign-in didn't cover inviting them, and takes the event without them", async () => { + getConnectionWithCredential.mockResolvedValue(Ok(connection('work', 'google'))); + unavailableActions.mockImplementation(() => ['inviteGuests']); + const {requireWritable} = await import('./sources'); + + const refused = await requireWritable('org-1', 'work', 'events', 'create', ['title', 'start', 'guests']); + expect(refused.unwrapErr()).toMatchObject({kind: 'validation_error', message: 'not allowed: inviteGuests'}); + + const allowed = await requireWritable('org-1', 'work', 'events', 'create', ['title', 'start']); + expect(allowed.unwrap().collection.id).toBe('events'); + }); + + test('refuses a collection the connection has none of', async () => { + getConnectionWithCredential.mockResolvedValue(Ok(connection('imap', 'email'))); + const {requireWritable} = await import('./sources'); + + expect((await requireWritable('org-1', 'imap', 'events', 'create', [])).unwrapErr().kind).toBe('not_found'); + }); +}); diff --git a/packages/api/src/widgets/sources.ts b/packages/api/src/widgets/sources.ts new file mode 100644 index 00000000..01b8ffed --- /dev/null +++ b/packages/api/src/widgets/sources.ts @@ -0,0 +1,77 @@ +import {Err, type Result} from 'ts-results-es'; + +import {ApiErr, type ApiError, Do} from '@proxy/utils'; +import {listConnectionsWithCredential} from '@proxy/db/connection'; +import {findCollection, requiredAction, type Collection, type Integration} from '@proxy/integrations'; + +import {unavailableActions, unavailableError} from '../connections/unavailable'; +import type {Connector, RecordTarget} from '../records/connector'; +import {connectorFor} from '../records/connectors'; +import {loadRecordTarget} from '../records/target'; +import {integrationOf} from '../utils/connection-actions'; + +/** + * Where the home page's widgets read and write: every connection of the organization with a + * collection of the kind a widget is about, as the catalog names it (`events`, `contacts`, + * `emails`, `files`). A collection's id is the contract: every integration with an `emails` + * collection has the same fields, so a widget works with any of them, and connecting a new + * integration with the collection lights the widget up. The person owns the connections, so + * nothing limits them but what the sign-in covered. + */ + +export type Source = RecordTarget & {integration: Integration; connector: Connector}; + +/** What the browser is told of a source: which account it is, to tell several apart. */ +export type SourceSummary = {connectionId: string; integrationId: string; account: string; name: string | null}; + +export function summarize(source: Source): SourceSummary { + const {id, integrationId, account, name} = source.connection; + return {connectionId: id, integrationId, account, name}; +} + +/** + * The organization's connections with the collection, where its sign-in covered `request`: `list` + * to read it, `create` to write to it, or a command's id, such as `sendNew`. + */ +export function findSources(orgId: string, collectionId: string, request: string): Promise> { + return Do(async ($) => { + const connections = $(await listConnectionsWithCredential(orgId)); + return connections.flatMap((connection): Source[] => { + const integration = integrationOf(connection); + const collection = integration ? findCollection(integration, collectionId) : undefined; + const connector = connectorFor(connection.integrationId); + if (!integration || !collection || !connector || !covers(connection, collection, request)) { + return []; + } + return [{connection, collection, integration, connector}]; + }); + }); +} + +function covers(connection: {id: string; integrationId: string; credential: string | null}, collection: Collection, request: string): boolean { + const action = requiredAction(collection, request); + return action !== null && !unavailableActions(connection).includes(action); +} + +/** + * One connection to write to, with the collection: `request` is `create` or a command's id, such + * as `sendNew`, and `fields` the fields written, some of which need an action of their own, as an + * event's guests need Invite guests. One whose sign-in didn't cover them all is refused, saying so. + */ +export function requireWritable(orgId: string, connectionId: string, collectionId: string, request: string, fields: string[]): Promise> { + return Do(async ($) => { + const target = $(await loadRecordTarget(orgId, connectionId, collectionId)); + const integration = integrationOf(target.connection); + const action = requiredAction(target.collection, request); + if (!integration || action === null) { + return $(Err(ApiErr.forbidden())); + } + const fieldActions = target.collection.fields.flatMap((field) => (field.action && fields.includes(field.key) ? [field.action] : [])); + const unavailable = unavailableActions(target.connection); + const missing = [action, ...fieldActions].find((needed) => unavailable.includes(needed)); + if (missing !== undefined) { + return $(Err(unavailableError(integration, missing))); + } + return {...target, integration}; + }); +} diff --git a/packages/api/src/widgets/writes.test.ts b/packages/api/src/widgets/writes.test.ts new file mode 100644 index 00000000..6265fae3 --- /dev/null +++ b/packages/api/src/widgets/writes.test.ts @@ -0,0 +1,156 @@ +import {beforeEach, describe, expect, mock, test} from 'bun:test'; +import {Err, Ok} from 'ts-results-es'; + +import {ApiErr} from '@proxy/utils'; +import {findCollection, findIntegration} from '@proxy/integrations'; + +const requireWritable = mock(); +const afterWrite = mock(async () => {}); +const create = mock(); +const sendNew = mock(); +const get = mock(); +const update = mock(); + +// The Google client and the mail connectors read the environment on import, which tests don't have. +mock.module('../utils/env', () => ({env: {NODE_ENV: 'test'}})); +mock.module('./sources', () => ({requireWritable, findSources: mock(), summarize: mock()})); +mock.module('./writes', () => ({afterWrite})); +mock.module('../utils/user-org', () => ({requireUserOrgId: async () => Ok('org-1')})); + +const events = findCollection(findIntegration('google') ?? {collections: []}, 'events'); +const source = {connection: {id: 'work'}, collection: events, connector: {create, get, update, newCommands: {sendNew}}}; + +function request(body: unknown) { + return {user: {userId: 'user-1'}, body} as never; +} + +const event = {connectionId: 'work', calendarId: 'team', title: ' Lunch ', start: '2026-10-15T13:00:00+02:00', end: '2026-10-15T13:30:00+02:00', location: 'Nopa'}; + +beforeEach(() => { + mock.clearAllMocks(); + requireWritable.mockResolvedValue(Ok(source)); + create.mockResolvedValue(Ok({id: 'team:e1', values: {title: 'Lunch'}, updatedAt: '', url: 'https://calendar.google.com/e1'})); + sendNew.mockResolvedValue(Ok(null)); +}); + +describe('handleCreateEventRoute', () => { + test('makes the event in the calendar picked through its connector, logs it and keeps the calendar', async () => { + const {handleCreateEventRoute} = await import('./new-event'); + + const made = await handleCreateEventRoute(request({...event, guests: ['jacky@example.com', 'sam@example.com']})); + expect(made.unwrap()).toEqual({title: 'Lunch', link: 'https://calendar.google.com/e1'}); + expect(requireWritable).toHaveBeenCalledWith('org-1', 'work', 'events', 'create', ['title', 'start', 'end', 'location', 'guests']); + expect(create).toHaveBeenCalledWith(source, {title: 'Lunch', start: event.start, end: event.end, location: 'Nopa', guests: 'jacky@example.com, sam@example.com'}, 'team'); + expect(afterWrite).toHaveBeenCalledWith({orgId: 'org-1', userId: 'user-1', source, action: 'create', title: 'Lunch', parent: 'team'}); + }); + + test("doesn't ask for Invite guests for an event without them", async () => { + const {handleCreateEventRoute} = await import('./new-event'); + + await handleCreateEventRoute(request(event)); + expect(requireWritable).toHaveBeenCalledWith('org-1', 'work', 'events', 'create', ['title', 'start', 'end', 'location']); + }); + + test('writes nothing and logs nothing when refused', async () => { + requireWritable.mockResolvedValue(Err(ApiErr.validationError("Google didn't allow Invite guests at sign-in."))); + const {handleCreateEventRoute} = await import('./new-event'); + + expect((await handleCreateEventRoute(request({...event, guests: ['jacky@example.com']}))).unwrapErr().kind).toBe('validation_error'); + expect(create).not.toHaveBeenCalled(); + expect(afterWrite).not.toHaveBeenCalled(); + }); + + test('refuses times without an offset and guests that are no addresses', async () => { + const {handleCreateEventRoute} = await import('./new-event'); + + expect((await handleCreateEventRoute(request({...event, start: '2026-10-15T13:00'}))).unwrapErr().kind).toBe('parse_error'); + expect((await handleCreateEventRoute(request({...event, guests: ['Jacky']}))).unwrapErr().kind).toBe('parse_error'); + expect(requireWritable).not.toHaveBeenCalled(); + }); +}); + +describe('handleWriteEmailRoute', () => { + const email = {connectionId: 'work', to: ['jacky@example.com'], subject: 'Lunch', body: 'Tuesday?'}; + + test('sends a new email with the mailbox’s own command, logged as sent', async () => { + const {handleWriteEmailRoute} = await import('./emails'); + + expect((await handleWriteEmailRoute(request({...email, send: true}))).unwrap()).toEqual({sent: true}); + expect(requireWritable).toHaveBeenCalledWith('org-1', 'work', 'emails', 'sendNew', ['to', 'subject', 'body']); + expect(sendNew).toHaveBeenCalledWith(source, {to: 'jacky@example.com', subject: 'Lunch', body: 'Tuesday?'}); + expect(create).not.toHaveBeenCalled(); + expect(afterWrite).toHaveBeenCalledWith(expect.objectContaining({action: 'sendNew', title: 'Lunch', parent: null})); + }); + + test('saves a draft as creating an email', async () => { + const {handleWriteEmailRoute} = await import('./emails'); + + expect((await handleWriteEmailRoute(request({...email, send: false}))).unwrap()).toEqual({sent: false}); + expect(requireWritable).toHaveBeenCalledWith('org-1', 'work', 'emails', 'create', ['to', 'subject', 'body']); + expect(create).toHaveBeenCalledWith(source, {to: 'jacky@example.com', subject: 'Lunch', body: 'Tuesday?'}, null); + expect(sendNew).not.toHaveBeenCalled(); + }); + + test('takes no email without a recipient', async () => { + const {handleWriteEmailRoute} = await import('./emails'); + + expect((await handleWriteEmailRoute(request({...email, to: [], send: true}))).unwrapErr().kind).toBe('parse_error'); + }); +}); + +describe('handleEditEventRoute', () => { + const lunch = { + id: 'team:e1', + values: { + title: 'Lunch', + kind: 'Event', + start: '2026-10-15T13:00:00+02:00', + end: '2026-10-15T13:30:00+02:00', + location: 'Nopa', + guests: 'jacky@example.com', + description: 'Agenda', + organizer: '', + answers: 'Jacky : Accepted', + }, + updatedAt: '', + }; + const moved = {connectionId: 'work', calendarId: 'team', eventId: 'e1', start: '2026-10-16T15:00:00+02:00', end: '2026-10-16T15:30:00+02:00'}; + + beforeEach(() => { + get.mockResolvedValue(Ok(lunch)); + update.mockResolvedValue(Ok({...lunch, values: {...lunch.values, start: moved.start, end: moved.end}, url: 'https://calendar.google.com/e1'})); + }); + + test('changes only what is given, the rest as the event is, through its connector', async () => { + const {handleEditEventRoute} = await import('./edit-event'); + + const changed = await handleEditEventRoute(request(moved)); + expect(changed.unwrap()).toEqual({title: 'Lunch', link: 'https://calendar.google.com/e1'}); + expect(get).toHaveBeenCalledWith(source, 'team:e1'); + expect(update).toHaveBeenCalledWith(source, 'team:e1', {title: 'Lunch', start: moved.start, end: moved.end, location: 'Nopa', guests: 'jacky@example.com', description: 'Agenda'}, lunch); + // Its guests are told, which needs Invite guests too. + expect(requireWritable).toHaveBeenCalledWith('org-1', 'work', 'events', 'update', ['start', 'end']); + expect(requireWritable).toHaveBeenCalledWith('org-1', 'work', 'events', 'update', ['guests']); + // A change picks no calendar for the next new event. + expect(afterWrite).toHaveBeenCalledWith({orgId: 'org-1', userId: 'user-1', source, action: 'update', title: 'Lunch', parent: undefined}); + }); + + test('writes the guests as given', async () => { + const {handleEditEventRoute} = await import('./edit-event'); + + await handleEditEventRoute(request({connectionId: 'work', calendarId: 'team', eventId: 'e1', guests: ['jacky@example.com', 'hugo@example.com']})); + expect(requireWritable).toHaveBeenCalledTimes(1); + expect(update.mock.calls[0]?.[2]).toMatchObject({guests: 'jacky@example.com, hugo@example.com', start: lunch.values.start}); + }); + + test('changes no event someone else organizes, and no calendar', async () => { + const {handleEditEventRoute} = await import('./edit-event'); + + get.mockResolvedValueOnce(Ok({...lunch, values: {...lunch.values, organizer: 'Sam Lee '}})); + expect((await handleEditEventRoute(request(moved))).unwrapErr()).toEqual(ApiErr.validationError('Only an event you organize can be changed here')); + get.mockResolvedValueOnce(Ok({...lunch, values: {...lunch.values, kind: 'Calendar'}})); + expect((await handleEditEventRoute(request(moved))).unwrapErr().kind).toBe('not_found'); + expect(update).not.toHaveBeenCalled(); + expect(afterWrite).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/widgets/writes.ts b/packages/api/src/widgets/writes.ts new file mode 100644 index 00000000..c484b44e --- /dev/null +++ b/packages/api/src/widgets/writes.ts @@ -0,0 +1,29 @@ +import {Ok} from 'ts-results-es'; + +import {logProxyAction} from '@proxy/db/audit'; +import {saveWriteTarget} from '@proxy/db/write-target'; + +import {log} from '../observability/log'; +import type {Source} from './sources'; + +/** + * What follows a widget's write: the activity log's entry, as done by Personal Agent Proxy for the + * person rather than by an agent, and the place written to, which the widget starts on next time, + * unless `parent` is `undefined`, as for a change to something already there, which picks nowhere. + * The write is done by then, so neither failing fails it; each is logged instead. + */ +export async function afterWrite(args: {orgId: string; userId: string; source: Source; action: string; title: string; parent: string | null | undefined}): Promise { + const {orgId, userId, source, action, title, parent} = args; + const connectionId = source.connection.id; + const collectionId = source.collection.id; + const [logged, saved] = await Promise.all([ + logProxyAction({orgId, connectionId, collectionId, action, recordTitle: title || null}), + parent === undefined ? Ok(null) : saveWriteTarget({userId, collectionId, connectionId, parent}), + ]); + if (logged.isErr()) { + log.warn('A widget’s write could not be logged', {connectionId, collectionId, action}); + } + if (saved.isErr()) { + log.warn('Where a widget wrote could not be kept', {connectionId, collectionId}); + } +} diff --git a/packages/db/package.json b/packages/db/package.json index 6802c410..43589aa1 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -14,7 +14,8 @@ "./instance-meta": "./src/instance-meta.ts", "./oauth": "./src/oauth.ts", "./organization": "./src/organization.ts", - "./user": "./src/user.ts" + "./user": "./src/user.ts", + "./write-target": "./src/write-target.ts" }, "scripts": { "test": "bun test", diff --git a/packages/db/prisma/migrations/v020_add_write_targets/migration.sql b/packages/db/prisma/migrations/v020_add_write_targets/migration.sql new file mode 100644 index 00000000..bc62e423 --- /dev/null +++ b/packages/db/prisma/migrations/v020_add_write_targets/migration.sql @@ -0,0 +1,21 @@ +-- What Personal Agent Proxy does itself for a person, as the home page's widgets write, is logged +-- beside the agents' requests, without an agent. +ALTER TABLE "AuditEntry" ALTER COLUMN "agentId" DROP NOT NULL; + +-- Where the home page's widgets write when there is more than one place: the last calendar or +-- mailbox each person wrote to, per collection. +CREATE TABLE "WriteTarget" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "collectionId" TEXT NOT NULL, + "connectionId" TEXT NOT NULL, + "parent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "WriteTarget_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "WriteTarget_connectionId_idx" ON "WriteTarget"("connectionId"); + +CREATE UNIQUE INDEX "WriteTarget_userId_collectionId_key" ON "WriteTarget"("userId", "collectionId"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index f44571c5..8b743091 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -34,9 +34,10 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt - accounts Account[] - sessions Session[] - orgMembers OrgMember[] + accounts Account[] + sessions Session[] + orgMembers OrgMember[] + writeTargets WriteTarget[] } // A sign-in provider identity linked to a user, such as their Google account. @@ -153,15 +154,38 @@ model Connection { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt - org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) - defaults ConnectionDefault[] - infoRecords InfoRecord[] - agentGrants AgentGrant[] + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + defaults ConnectionDefault[] + infoRecords InfoRecord[] + agentGrants AgentGrant[] + writeTargets WriteTarget[] @@index([orgId]) @@index([integrationId, providerAccountId]) } +// Where the home page's widgets write when there is more than one place: the calendar a new event +// goes in, the mailbox an email goes out from. The last place each person wrote to, per +// collection; none until they first write, and gone with the connection. +model WriteTarget { + id String @id @default(cuid()) + userId String + // The collection written to, as the catalog names it: `events`, `emails`. + collectionId String + connectionId String + // Where in the connection, in a nested collection: a calendar's id. + parent String? + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + + @@unique([userId, collectionId]) + @@index([connectionId]) +} + // An action of the connection that agents get by default, such as `read` or `archive` on a // mailbox. The actions are the catalog's (`@proxy/integrations`); no row is off. model ConnectionDefault { @@ -265,12 +289,14 @@ enum AuditVia { mcp } -// One request an agent made, allowed or denied. Plain ids rather than relations, so the entry -// stays when the agent or the connection is gone. +// One request an agent made, allowed or denied, or one thing Personal Agent Proxy did itself for a +// person, as the home page's widgets write. Plain ids rather than relations, so the entry stays +// when the agent or the connection is gone. model AuditEntry { id String @id @default(cuid()) orgId String - agentId String + // None for what Personal Agent Proxy did itself. + agentId String? connectionId String collectionId String // list, view, create, update or delete, or one of the collection's commands, such as `send`. diff --git a/packages/db/src/audit.test.ts b/packages/db/src/audit.test.ts new file mode 100644 index 00000000..0da09ae6 --- /dev/null +++ b/packages/db/src/audit.test.ts @@ -0,0 +1,33 @@ +import {describe, expect, test} from 'bun:test'; + +import {Prisma} from '@prisma/client'; +import {ApiErr} from '@proxy/utils'; + +import {unknownTimeZoneAs} from './audit'; + +function rawQueryFailed(code: string, message: string) { + return ApiErr.dbError( + new Prisma.PrismaClientKnownRequestError('Raw query failed', { + code: 'P2010', + clientVersion: 'test', + meta: {code, message}, + }), + ); +} + +describe('unknownTimeZoneAs', () => { + test('turns a time zone the database has no rules for into a validation error, and nothing else', () => { + const asValidation = unknownTimeZoneAs('America/Ciudad_Juarez'); + const unknown = rawQueryFailed( + '22023', + 'ERROR: time zone "America/Ciudad_Juarez" not recognized', + ); + const other = rawQueryFailed('57014', 'ERROR: canceling statement due to statement timeout'); + + expect(asValidation(unknown)).toEqual( + ApiErr.validationError('The database doesn’t know the time zone America/Ciudad_Juarez'), + ); + expect(asValidation(other)).toBe(other); + expect(asValidation(ApiErr.forbidden())).toEqual(ApiErr.forbidden()); + }); +}); diff --git a/packages/db/src/audit.ts b/packages/db/src/audit.ts index 2033a66d..2122964a 100644 --- a/packages/db/src/audit.ts +++ b/packages/db/src/audit.ts @@ -1,13 +1,14 @@ -import type {AuditOutcome, AuditVia} from '@prisma/client'; +import {type AuditOutcome, type AuditVia, Prisma} from '@prisma/client'; import type {Result} from 'ts-results-es'; -import {type ApiError, wrapDb} from '@proxy/utils'; +import {ApiErr, type ApiError, wrapDb} from '@proxy/utils'; import {db} from '.'; export type AuditEntryRow = { id: string; - agentId: string; + // None for what Personal Agent Proxy did itself. + agentId: string | null; connectionId: string; collectionId: string; // list, view, create, update or delete, or a command of the collection, such as `send`. @@ -39,15 +40,46 @@ export async function logAgentRequest(data: { }); } -/** Newest first, optionally only one agent's or one connection's requests. */ +/** + * Logs one thing Personal Agent Proxy did itself for a person, such as making an event from the + * home page: an entry without an agent, always allowed, since the person asked for it. + */ +export async function logProxyAction(data: { + orgId: string; + connectionId: string; + collectionId: string; + action: string; + recordTitle: string | null; +}): Promise> { + return wrapDb(async () => { + await db.auditEntry.create({data: {...data, agentId: null, query: null, outcome: 'allowed'}}); + }); +} + +/** + * Newest first, optionally only one agent's or one connection's requests, only allowed or denied + * ones, or only those made from `from` until before `to`; an `agentId` of null is what Personal + * Agent Proxy did itself. + */ export async function listAuditEntries( orgId: string, - filter: {agentId?: string; connectionId?: string}, + filter: { + agentId?: string | null; + connectionId?: string; + outcome?: AuditOutcome; + from?: Date; + to?: Date; + }, take: number, ): Promise> { + const {from, to, ...rest} = filter; + const createdAt = + from === undefined && to === undefined + ? {} + : {createdAt: {...(from ? {gte: from} : {}), ...(to ? {lt: to} : {})}}; return wrapDb(() => db.auditEntry.findMany({ - where: {orgId, ...filter}, + where: {orgId, ...rest, ...createdAt}, orderBy: {createdAt: 'desc'}, take, select: { @@ -65,3 +97,91 @@ export async function listAuditEntries( }), ); } + +// The furthest any time zone's clock is ahead of UTC (Kiribati's): a day there starts 14 hours +// before it does in UTC, so the entries of a day on and after it are all made after this before it. +const MAX_OFFSET_MS = 14 * 60 * 60 * 1000; + +function earliestInstantOf(day: string): Date { + return new Date(Date.parse(`${day}T00:00:00Z`) - MAX_OFFSET_MS); +} + +// The database refusing a time zone it has no rules for, `time zone "…" not recognized`: one the +// runtime knows but the database's time zone data, older or newer, doesn't. +export function unknownTimeZoneAs(timeZone: string) { + return (error: ApiError): ApiError => { + const cause = error.kind === 'db_error' ? error.cause : null; + if ( + !(cause instanceof Prisma.PrismaClientKnownRequestError) || + cause.code !== 'P2010' || + cause.meta?.code !== '22023' + ) { + return error; + } + return ApiErr.validationError(`The database doesn’t know the time zone ${timeZone}`); + }; +} + +export type AuditDayCount = { + // `2026-10-16`, on the time zone's clock. + day: string; + agentId: string | null; + count: number; + denied: number; +}; + +/** + * How many requests each agent made each day from `fromDay` on, and how many of them were denied, + * the days on the time zone's clock; days without any are left out. An `agentId` of null is what + * Personal Agent Proxy did itself. + */ +export async function countAuditEntriesByDay( + orgId: string, + timeZone: string, + fromDay: string, +): Promise> { + // `createdAt` is a UTC time without its zone: read as UTC, then onto the time zone's clock. + const counted = await wrapDb( + () => + db.$queryRaw` + SELECT to_char(("createdAt" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}, 'YYYY-MM-DD') AS day, + "agentId", + count(*)::int AS count, + (count(*) FILTER (WHERE outcome = 'denied'))::int AS denied + FROM "AuditEntry" + WHERE "orgId" = ${orgId} + AND "createdAt" >= ${earliestInstantOf(fromDay)} + AND (("createdAt" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone})::date >= ${fromDay}::date + GROUP BY 1, 2 + ORDER BY 1, 2`, + ); + return counted.mapErr(unknownTimeZoneAs(timeZone)); +} + +export type AuditActionCount = { + connectionId: string; + collectionId: string; + action: string; + outcome: AuditOutcome; + count: number; +}; + +/** How many times each action was requested of each collection from `fromDay` on, the time zone's. */ +export async function countAuditEntriesByAction( + orgId: string, + timeZone: string, + fromDay: string, +): Promise> { + const counted = await wrapDb( + () => + db.$queryRaw` + SELECT "connectionId", "collectionId", action, outcome, count(*)::int AS count + FROM "AuditEntry" + WHERE "orgId" = ${orgId} + AND "createdAt" >= ${earliestInstantOf(fromDay)} + AND (("createdAt" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone})::date >= ${fromDay}::date + GROUP BY 1, 2, 3, 4 + ORDER BY count DESC`, + ); + return counted.mapErr(unknownTimeZoneAs(timeZone)); +} diff --git a/packages/db/src/write-target.ts b/packages/db/src/write-target.ts new file mode 100644 index 00000000..081c9b17 --- /dev/null +++ b/packages/db/src/write-target.ts @@ -0,0 +1,37 @@ +import type {Result} from 'ts-results-es'; + +import {type ApiError, wrapDb} from '@proxy/utils'; + +import {db} from '.'; + +/** Where a person last wrote to a collection from the home page: a connection, and in it a calendar. */ +export type WriteTargetRow = {connectionId: string; parent: string | null}; + +/** `Ok(null)` until the person first writes to the collection, or once its connection is gone. */ +export async function getWriteTarget( + userId: string, + collectionId: string, +): Promise> { + return wrapDb(() => + db.writeTarget.findUnique({ + where: {userId_collectionId: {userId, collectionId}}, + select: {connectionId: true, parent: true}, + }), + ); +} + +export async function saveWriteTarget(data: { + userId: string; + collectionId: string; + connectionId: string; + parent: string | null; +}): Promise> { + const {userId, collectionId, connectionId, parent} = data; + return wrapDb(async () => { + await db.writeTarget.upsert({ + where: {userId_collectionId: {userId, collectionId}}, + create: data, + update: {connectionId, parent}, + }); + }); +} diff --git a/packages/integrations/src/catalog.test.ts b/packages/integrations/src/catalog.test.ts index 16a73285..909fc50b 100644 --- a/packages/integrations/src/catalog.test.ts +++ b/packages/integrations/src/catalog.test.ts @@ -20,4 +20,24 @@ describe('catalog', () => { } } }); + + // The home page's widgets read these collections by their ids from every integration that has + // them (all of a person's mailboxes, every calendar), so their fields are one contract. Other + // ids may mean different things: Information's notes aren't Granola's. + test('gives the collections the widgets read the same fields in every integration', () => { + const read = ['emails', 'events', 'contacts', 'files']; + const collections = INTEGRATIONS.flatMap((integration) => + integration.collections + .filter((collection) => read.includes(collection.id)) + .map((collection) => ({integration: integration.id, collection})), + ); + for (const {integration, collection} of collections) { + const first = collections.find((candidate) => candidate.collection.id === collection.id); + expect( + collection.fields.map((field) => [field.key, field.type]), + `${integration}'s ${collection.id} differs from ${first?.integration}'s`, + ).toEqual(first?.collection.fields.map((field) => [field.key, field.type]) ?? []); + } + expect(collections.filter(({collection}) => collection.id === 'emails')).toHaveLength(2); + }); }); diff --git a/packages/utils/package.json b/packages/utils/package.json index 5456b6ac..68a422bb 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -4,7 +4,9 @@ "version": "0.0.0", "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./days": "./src/days.ts", + "./units": "./src/units.ts" }, "scripts": { "test": "bun test", diff --git a/packages/utils/src/api-error.ts b/packages/utils/src/api-error.ts index 18328d1d..a56efda4 100644 --- a/packages/utils/src/api-error.ts +++ b/packages/utils/src/api-error.ts @@ -13,7 +13,9 @@ export type ApiError = // A provider turned the credential down, such as a wrong app password. | {kind: 'credentials_rejected'; statusCode: 422} // A provider couldn't be reached or answered with something other than a verdict. - | {kind: 'provider_unreachable'; statusCode: 502; cause: unknown}; + | {kind: 'provider_unreachable'; statusCode: 502; cause: unknown} + // Something this server needs for the request isn't set up, such as a key or a model. + | {kind: 'unavailable'; statusCode: 503; message: string}; export const ApiErr = { notFound: (resource: string, id?: string): ApiError => ({ @@ -45,6 +47,7 @@ export const ApiErr = { statusCode: 502, cause, }), + unavailable: (message: string): ApiError => ({kind: 'unavailable', statusCode: 503, message}), }; function isApiError(error: unknown): error is ApiError { diff --git a/packages/utils/src/days.test.ts b/packages/utils/src/days.test.ts new file mode 100644 index 00000000..941e53ac --- /dev/null +++ b/packages/utils/src/days.test.ts @@ -0,0 +1,40 @@ +import {describe, expect, test} from 'bun:test'; + +import {addDays, dayIn, daysBetween, isTimeZone, weekdayOf} from './days'; + +describe('days', () => { + test('the day is the time zone’s, half-hour and quarter-hour offsets too', () => { + const at = new Date('2026-10-15T23:30:00Z'); + expect(dayIn(at, 'UTC')).toBe('2026-10-15'); + expect(dayIn(at, 'Europe/Berlin')).toBe('2026-10-16'); + expect(dayIn(at, 'America/Los_Angeles')).toBe('2026-10-15'); + expect(dayIn(new Date('2026-10-15T18:10:00Z'), 'Asia/Kathmandu')).toBe('2026-10-15'); + expect(dayIn(new Date('2026-10-15T18:20:00Z'), 'Asia/Kathmandu')).toBe('2026-10-16'); + }); + + test('adds days across months, years and leap days', () => { + expect(addDays('2026-10-16', 1)).toBe('2026-10-17'); + expect(addDays('2026-10-31', 1)).toBe('2026-11-01'); + expect(addDays('2027-01-01', -1)).toBe('2026-12-31'); + expect(addDays('2028-02-28', 1)).toBe('2028-02-29'); + expect(daysBetween('2026-10-01', '2026-10-16')).toBe(15); + expect(daysBetween('2026-10-16', '2026-10-01')).toBe(-15); + }); + + test('weeks start on Monday', () => { + expect(weekdayOf('2026-10-12')).toBe(0); + expect(weekdayOf('2026-10-16')).toBe(4); + expect(weekdayOf('2026-10-18')).toBe(6); + }); + + test('knows time zones and their aliases, and nothing else', () => { + expect(isTimeZone('Europe/Berlin')).toBe(true); + expect(isTimeZone('Asia/Calcutta')).toBe(true); + expect(isTimeZone('UTC')).toBe(true); + expect(isTimeZone('Mars/Olympus')).toBe(false); + expect(isTimeZone("UTC'; DROP TABLE")).toBe(false); + expect(isTimeZone('Etc/GMT+5')).toBe(true); + expect(isTimeZone('+05:00')).toBe(false); + expect(isTimeZone('-05')).toBe(false); + }); +}); diff --git a/packages/utils/src/days.ts b/packages/utils/src/days.ts new file mode 100644 index 00000000..5ac0a1f9 --- /dev/null +++ b/packages/utils/src/days.ts @@ -0,0 +1,46 @@ +import {Result} from 'ts-results-es'; + +// Days as a calendar names them, `2026-10-16`, without a time or a clock: what the activity graph +// counts by, in the person's time zone. + +const DAY_MS = 24 * 60 * 60 * 1000; + +// A time zone's name, `Europe/Berlin` or `UTC`. An offset such as `+05:00`, which the runtime takes +// too, isn't one: Postgres reads it the POSIX way, as five hours behind UTC rather than ahead. +const ZONE_NAME = /^[A-Za-z][A-Za-z0-9_+-]*(?:\/[A-Za-z0-9_+-]+)*$/; + +/** Whether the runtime knows the IANA time zone, such as `Europe/Berlin`, aliases included. */ +export function isTimeZone(value: string): boolean { + return ( + ZONE_NAME.test(value) && + Result.wrap(() => new Intl.DateTimeFormat('en-US', {timeZone: value})).isOk() + ); +} + +/** The day it is at the instant on the time zone's clock. */ +export function dayIn(at: Date, timeZone: string): string { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(at); + const part = (type: 'year' | 'month' | 'day') => + parts.find((candidate) => candidate.type === type)?.value ?? ''; + return `${part('year')}-${part('month')}-${part('day')}`; +} + +/** The day `count` days after (or, negative, before) it. */ +export function addDays(day: string, count: number): string { + return new Date(Date.parse(`${day}T00:00:00Z`) + count * DAY_MS).toISOString().slice(0, 10); +} + +/** The day of the week, 0 for Monday to 6 for Sunday. */ +export function weekdayOf(day: string): number { + return (new Date(`${day}T00:00:00Z`).getUTCDay() + 6) % 7; +} + +/** How many days the second day is after the first. */ +export function daysBetween(from: string, to: string): number { + return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY_MS); +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 97d66fd7..542e0e21 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -5,6 +5,7 @@ export {EXPERIMENTS, hasExperiment, isExperiment, organizationExperiments} from export type {Experiment} from './experiments'; export {formatFetchError, httpRequest, readJsonValidated} from './fetch'; export type {FetchError} from './fetch'; +export {mapWithLimit} from './map-with-limit'; export {parseSchema, requirePresent} from './parse'; export { agentSessionCookieNames, diff --git a/packages/utils/src/map-with-limit.test.ts b/packages/utils/src/map-with-limit.test.ts new file mode 100644 index 00000000..955188bb --- /dev/null +++ b/packages/utils/src/map-with-limit.test.ts @@ -0,0 +1,18 @@ +import {expect, test} from 'bun:test'; + +import {mapWithLimit} from './map-with-limit'; + +test('mapWithLimit keeps the order and never runs more than the limit at once', async () => { + let running = 0; + let most = 0; + const results = await mapWithLimit([5, 1, 4, 2, 3, 0, 6], 3, async (value) => { + running += 1; + most = Math.max(most, running); + await Bun.sleep(value); + running -= 1; + return value * 10; + }); + + expect(results).toEqual([50, 10, 40, 20, 30, 0, 60]); + expect(most).toBe(3); +}); diff --git a/packages/utils/src/map-with-limit.ts b/packages/utils/src/map-with-limit.ts new file mode 100644 index 00000000..58e7b9ec --- /dev/null +++ b/packages/utils/src/map-with-limit.ts @@ -0,0 +1,13 @@ +/** Runs `work` on every item, at most `limit` at once, the results in the items' order. */ +export async function mapWithLimit(items: T[], limit: number, work: (item: T) => Promise): Promise { + const results = new Array(items.length); + // One iterator shared by every worker, so each item is taken once. + const queue = items.entries(); + const worker = async (): Promise => { + for (const [index, item] of queue) { + results[index] = await work(item); + } + }; + await Promise.all(Array.from({length: Math.min(limit, items.length)}, worker)); + return results; +} diff --git a/packages/utils/src/units.test.ts b/packages/utils/src/units.test.ts new file mode 100644 index 00000000..4fbb2aa1 --- /dev/null +++ b/packages/utils/src/units.test.ts @@ -0,0 +1,46 @@ +import {describe, expect, test} from 'bun:test'; + +import {convert, findUnit, UNITS, type Unit} from './units'; + +function unit(id: string): Unit { + const found = findUnit(id); + if (!found) { + throw new Error(`No unit ${id}`); + } + return found; +} + +describe('units', () => { + test('converts within a kind', () => { + expect(convert(5, unit('mi'), unit('km'))).toBeCloseTo(8.04672, 5); + expect(convert(2, unit('lb'), unit('kg'))).toBeCloseTo(0.90718, 5); + expect(convert(3, unit('cup'), unit('ml'))).toBeCloseTo(709.765, 3); + expect(convert(100, unit('kmh'), unit('mph'))).toBeCloseTo(62.137, 3); + expect(convert(1, unit('gb'), unit('mb'))).toBe(1000); + }); + + test('moves a temperature’s zero', () => { + expect(convert(72, unit('f'), unit('c'))).toBeCloseTo(22.222, 3); + expect(convert(-40, unit('c'), unit('f'))).toBeCloseTo(-40, 6); + expect(convert(0, unit('k'), unit('c'))).toBeCloseTo(-273.15, 6); + }); + + test("converts money at the day's rates, and none without them", () => { + const rates = {EUR: 1, USD: 1.1195, GBP: 0.84385}; + expect(convert(56, unit('usd'), unit('eur'), rates)).toBeCloseTo(50.0223, 4); + expect(convert(100, unit('gbp'), unit('usd'), rates)).toBeCloseTo(132.666, 3); + expect(convert(56, unit('usd'), unit('eur'))).toBeNull(); + expect(convert(56, unit('usd'), unit('jpy'), rates)).toBeNull(); + }); + + test('converts nothing between kinds', () => { + expect(convert(1, unit('kg'), unit('mi'))).toBeNull(); + }); + + test('gives every unit a counterpart of its kind, and every id once', () => { + for (const one of UNITS) { + expect(findUnit(one.counterpart)?.kind, one.id).toBe(one.kind); + } + expect(new Set(UNITS.map((one) => one.id)).size).toBe(UNITS.length); + }); +}); diff --git a/packages/utils/src/units.ts b/packages/utils/src/units.ts new file mode 100644 index 00000000..2f1aebb3 --- /dev/null +++ b/packages/utils/src/units.ts @@ -0,0 +1,192 @@ +/** + * The units the home page's Convert units widget knows: the api offers their ids to the model to + * pick from, so "miles", "mi." and "statute miles" are all `mi`, and the web app converts between + * them. Each is a number of its kind's base unit (metres, grams, litres…), but temperatures, + * which also move their zero, and currencies, whose worth is the day's exchange rate. + */ + +export type UnitKind = + | 'length' + | 'mass' + | 'volume' + | 'temperature' + | 'speed' + | 'area' + | 'time' + | 'data' + | 'energy' + | 'currency'; + +/** What one euro is worth in each currency, by its code (`USD`): the European Central Bank's of a day. */ +export type Rates = Record; + +// The currencies the European Central Bank publishes a rate for, and the euro. +const CURRENCIES: Array<[string, string]> = [ + ['EUR', 'euro'], + ['USD', 'US dollar'], + ['GBP', 'British pound'], + ['JPY', 'Japanese yen'], + ['CHF', 'Swiss franc'], + ['CAD', 'Canadian dollar'], + ['AUD', 'Australian dollar'], + ['NZD', 'New Zealand dollar'], + ['CNY', 'Chinese yuan'], + ['HKD', 'Hong Kong dollar'], + ['SGD', 'Singapore dollar'], + ['INR', 'Indian rupee'], + ['KRW', 'South Korean won'], + ['IDR', 'Indonesian rupiah'], + ['MYR', 'Malaysian ringgit'], + ['PHP', 'Philippine peso'], + ['THB', 'Thai baht'], + ['SEK', 'Swedish krona'], + ['NOK', 'Norwegian krone'], + ['DKK', 'Danish krone'], + ['ISK', 'Icelandic króna'], + ['PLN', 'Polish złoty'], + ['CZK', 'Czech koruna'], + ['HUF', 'Hungarian forint'], + ['RON', 'Romanian leu'], + ['TRY', 'Turkish lira'], + ['ILS', 'Israeli shekel'], + ['ZAR', 'South African rand'], + ['BRL', 'Brazilian real'], + ['MXN', 'Mexican peso'], +]; + +export type Unit = { + id: string; + kind: UnitKind; + // As a sentence writes it: "kilometre", "kilometres", and as a number is followed: "km". + name: string; + plural: string; + symbol: string; + // How many of the kind's base unit one is. + factor: number; + // What it is converted to when nothing is said: its metric or imperial counterpart. + counterpart: string; +}; + +const unit = ( + id: string, + kind: UnitKind, + name: string, + symbol: string, + factor: number, + counterpart: string, + plural = `${name}s`, +): Unit => ({id, kind, name, plural, symbol, factor, counterpart}); + +export const UNITS: Unit[] = [ + // Length, in metres. + unit('mm', 'length', 'millimetre', 'mm', 0.001, 'in'), + unit('cm', 'length', 'centimetre', 'cm', 0.01, 'in'), + unit('m', 'length', 'metre', 'm', 1, 'ft'), + unit('km', 'length', 'kilometre', 'km', 1000, 'mi'), + unit('in', 'length', 'inch', 'in', 0.0254, 'cm', 'inches'), + unit('ft', 'length', 'foot', 'ft', 0.3048, 'm', 'feet'), + unit('yd', 'length', 'yard', 'yd', 0.9144, 'm'), + unit('mi', 'length', 'mile', 'mi', 1609.344, 'km'), + unit('nmi', 'length', 'nautical mile', 'nmi', 1852, 'km'), + // Mass, in grams. + unit('mg', 'mass', 'milligram', 'mg', 0.001, 'g'), + unit('g', 'mass', 'gram', 'g', 1, 'oz'), + unit('kg', 'mass', 'kilogram', 'kg', 1000, 'lb'), + unit('t', 'mass', 'tonne', 't', 1_000_000, 'lb'), + unit('oz', 'mass', 'ounce', 'oz', 28.349523125, 'g'), + unit('lb', 'mass', 'pound', 'lb', 453.59237, 'kg'), + unit('st', 'mass', 'stone', 'st', 6350.29318, 'kg', 'stone'), + // Volume, in litres; cups, pints, quarts, gallons and fluid ounces the US's. + unit('ml', 'volume', 'millilitre', 'ml', 0.001, 'floz'), + unit('l', 'volume', 'litre', 'l', 1, 'gal'), + unit('m3', 'volume', 'cubic metre', 'm³', 1000, 'gal'), + unit('tsp', 'volume', 'teaspoon', 'tsp', 0.00492892159375, 'ml'), + unit('tbsp', 'volume', 'tablespoon', 'tbsp', 0.01478676478125, 'ml'), + unit('floz', 'volume', 'fluid ounce', 'fl oz', 0.0295735295625, 'ml'), + unit('cup', 'volume', 'cup', 'cup', 0.2365882365, 'ml'), + unit('pt', 'volume', 'pint', 'pt', 0.473176473, 'l'), + unit('qt', 'volume', 'quart', 'qt', 0.946352946, 'l'), + unit('gal', 'volume', 'gallon', 'gal', 3.785411784, 'l'), + // Temperature: converted by `convert` itself. + unit('c', 'temperature', 'degree Celsius', '°C', 1, 'f', 'degrees Celsius'), + unit('f', 'temperature', 'degree Fahrenheit', '°F', 1, 'c', 'degrees Fahrenheit'), + unit('k', 'temperature', 'kelvin', 'K', 1, 'c', 'kelvin'), + // Speed, in metres a second. + unit('mps', 'speed', 'metre per second', 'm/s', 1, 'kmh', 'metres per second'), + unit('kmh', 'speed', 'kilometre per hour', 'km/h', 1000 / 3600, 'mph', 'kilometres per hour'), + unit('mph', 'speed', 'mile per hour', 'mph', 1609.344 / 3600, 'kmh', 'miles per hour'), + unit('kn', 'speed', 'knot', 'kn', 1852 / 3600, 'kmh'), + // Area, in square metres. + unit('m2', 'area', 'square metre', 'm²', 1, 'ft2', 'square metres'), + unit('km2', 'area', 'square kilometre', 'km²', 1_000_000, 'mi2', 'square kilometres'), + unit('ft2', 'area', 'square foot', 'ft²', 0.09290304, 'm2', 'square feet'), + unit('mi2', 'area', 'square mile', 'mi²', 2_589_988.110336, 'km2', 'square miles'), + unit('ha', 'area', 'hectare', 'ha', 10_000, 'acre'), + unit('acre', 'area', 'acre', 'ac', 4046.8564224, 'ha'), + // Time, in seconds. + unit('s', 'time', 'second', 's', 1, 'min'), + unit('min', 'time', 'minute', 'min', 60, 'h'), + unit('h', 'time', 'hour', 'h', 3600, 'min'), + unit('d', 'time', 'day', 'd', 86_400, 'h'), + unit('wk', 'time', 'week', 'wk', 604_800, 'd'), + // Data, in bytes, a kilobyte a thousand of them. + unit('b', 'data', 'byte', 'B', 1, 'kb'), + unit('kb', 'data', 'kilobyte', 'kB', 1e3, 'mb'), + unit('mb', 'data', 'megabyte', 'MB', 1e6, 'gb'), + unit('gb', 'data', 'gigabyte', 'GB', 1e9, 'mb'), + unit('tb', 'data', 'terabyte', 'TB', 1e12, 'gb'), + // Energy, in joules; a calorie of food is a kilocalorie. + unit('j', 'energy', 'joule', 'J', 1, 'cal'), + unit('kj', 'energy', 'kilojoule', 'kJ', 1000, 'kcal'), + unit('cal', 'energy', 'calorie', 'cal', 4.184, 'j'), + unit('kcal', 'energy', 'kilocalorie', 'kcal', 4184, 'kj'), + unit('kwh', 'energy', 'kilowatt-hour', 'kWh', 3_600_000, 'kj'), + // Money, worth the day's rate: the dollar's counterpart the euro, every other's the dollar. + ...CURRENCIES.map(([code, name]) => + unit(code.toLowerCase(), 'currency', name, code, 1, code === 'USD' ? 'eur' : 'usd'), + ), +]; + +export function findUnit(id: string): Unit | undefined { + return UNITS.find((candidate) => candidate.id === id); +} + +// A temperature in Celsius, and back. +function toCelsius(value: number, from: string): number { + if (from === 'f') { + return ((value - 32) * 5) / 9; + } + if (from === 'k') { + return value - 273.15; + } + return value; +} + +function fromCelsius(value: number, to: string): number { + if (to === 'f') { + return (value * 9) / 5 + 32; + } + if (to === 'k') { + return value + 273.15; + } + return value; +} + +/** + * `value` of one unit in another of its kind; null for two kinds, as kilograms in miles, and for + * money without the day's `rates`, or a currency they have none for. + */ +export function convert(value: number, from: Unit, to: Unit, rates?: Rates): number | null { + if (from.kind !== to.kind) { + return null; + } + if (from.kind === 'currency') { + const fromRate = rates?.[from.symbol]; + const toRate = rates?.[to.symbol]; + return fromRate && toRate ? (value / fromRate) * toRate : null; + } + if (from.kind === 'temperature') { + return fromCelsius(toCelsius(value, from.id), to.id); + } + return (value * from.factor) / to.factor; +} diff --git a/packages/web/package.json b/packages/web/package.json index 0a06d357..168e3a92 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -16,6 +16,7 @@ "@proxy/ui": "workspace:*", "@proxy/utils": "workspace:*", "@tailwindcss/vite": "^4.3.3", + "chrono-node": "^2.10.3", "lucide-react": "^1.43.0", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/packages/web/src/client/agents-client.ts b/packages/web/src/client/agents-client.ts index 20df5b45..4ec44b2e 100644 --- a/packages/web/src/client/agents-client.ts +++ b/packages/web/src/client/agents-client.ts @@ -1,6 +1,6 @@ import type {AgentProviderId} from '@proxy/integrations'; import {z} from 'zod'; -import {agentSchema, auditEntrySchema} from '@/client/schemas'; +import {activitySummarySchema, agentSchema, auditEntrySchema} from '@/client/schemas'; import {apiRequest, apiSend} from '@/client/request'; export function listAgents() { @@ -58,7 +58,16 @@ export function disconnectMcpClient(agentId: string, clientId: string) { return apiSend('DELETE', `/api/agents/${agentId}/mcp-clients/${clientId}`); } -export function listActivity(filter: {agentId?: string; connectionId?: string}) { +export type ActivityFilter = { + agentId?: string; + connectionId?: string; + outcome?: 'allowed' | 'denied'; + // Only what was made from `from` until before `to`, ISO instants. + from?: string; + to?: string; +}; + +export function listActivity(filter: ActivityFilter) { const query = new URLSearchParams( Object.entries(filter).filter((entry): entry is [string, string] => entry[1] !== undefined), ); @@ -69,3 +78,12 @@ export function listActivity(filter: {agentId?: string; connectionId?: string}) z.object({entries: z.array(auditEntrySchema)}), ); } + +/** The home page's graph: each day's requests on the browser's clock, and the past week's by action. */ +export function getActivitySummary(timeZone: string) { + return apiRequest( + 'GET', + `/api/activity/summary?${new URLSearchParams({timeZone})}`, + activitySummarySchema, + ); +} diff --git a/packages/web/src/client/intent-client.ts b/packages/web/src/client/intent-client.ts new file mode 100644 index 00000000..0bf779b8 --- /dev/null +++ b/packages/web/src/client/intent-client.ts @@ -0,0 +1,18 @@ +import {z} from 'zod'; +import {apiRequest} from '@/client/request'; + +const intentSchema = z.object({ + // The widget's id, `none` when what was asked is whole and no widget does it, or `incomplete` + // when too little was typed to tell yet. + widget: z.string(), + // Its fields, each with what fills it: one value, or each of a list's. + fields: z.array(z.object({id: z.string(), name: z.string(), values: z.array(z.string())})), + ms: z.number(), +}); + +export type Intent = z.infer; + +/** Which widget fits what was typed, and its fields filled in (`POST /api/intent`). */ +export function classifyIntent(text: string) { + return apiRequest('POST', '/api/intent', intentSchema, {text}); +} diff --git a/packages/web/src/client/schemas.ts b/packages/web/src/client/schemas.ts index e24b22fb..08416cd1 100644 --- a/packages/web/src/client/schemas.ts +++ b/packages/web/src/client/schemas.ts @@ -42,7 +42,8 @@ export const dataRecordSchema = z.object({ export const auditEntrySchema = z.object({ id: z.string(), at: z.string(), - agentId: z.string(), + // None for what Personal Agent Proxy did itself, as the home page's widgets write. + agentId: z.string().nullable(), connectionId: z.string(), collectionId: z.string(), action: z.string(), @@ -52,3 +53,30 @@ export const auditEntrySchema = z.object({ // The web app's agent pages, or the MCP server. via: z.enum(['web', 'mcp']), }); + +export const activitySummarySchema = z.object({ + // Today on the browser's clock, `2026-10-16`. + today: z.string(), + // Each agent's requests each day, days without any left out; a null agent is Personal Agent + // Proxy's own. + days: z.array( + z.object({ + day: z.string(), + agentId: z.string().nullable(), + count: z.number(), + denied: z.number(), + }), + ), + // The past week's requests by action, the most first. + week: z.array( + z.object({ + connectionId: z.string(), + collectionId: z.string(), + action: z.string(), + outcome: z.enum(['allowed', 'denied']), + count: z.number(), + }), + ), +}); + +export type ActivitySummary = z.infer; diff --git a/packages/web/src/client/widgets-client.ts b/packages/web/src/client/widgets-client.ts new file mode 100644 index 00000000..014f2a93 --- /dev/null +++ b/packages/web/src/client/widgets-client.ts @@ -0,0 +1,255 @@ +import {z} from 'zod'; +import {apiRequest} from '@/client/request'; + +// The home page's widgets' reads and writes (`packages/api/src/widgets/`): every connection with +// the collection a widget is about, read at once, each answer saying which connections it read +// and which it couldn't. + +const sourceSchema = z.object({ + connectionId: z.string(), + integrationId: z.string(), + account: z.string(), + name: z.string().nullable(), +}); + +const failureSchema = z.object({ + connectionId: z.string(), + account: z.string(), + message: z.string(), +}); + +export type Source = z.infer; +export type Failure = z.infer; + +// A contact, or someone only in a mailbox's email, known by their name and address alone. +const personSchema = z.object({ + origin: z.enum(['contacts', 'email']), + connectionId: z.string(), + id: z.string(), + name: z.string(), + emails: z.array(z.string()), + phones: z.array(z.string()), + company: z.string(), + jobTitle: z.string(), + addresses: z.array(z.string()), + link: z.string().nullable(), +}); + +export type Person = z.infer; + +const peopleSchema = z.object({ + sources: z.array(sourceSchema), + people: z.array(z.object({name: z.string(), matches: z.array(personSchema)})), + failed: z.array(failureSchema), +}); + +export type People = z.infer; + +function query(params: Record): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + for (const one of value === null ? [] : Array.isArray(value) ? value : [value]) { + search.append(key, one); + } + } + return search.size > 0 ? `?${search.toString()}` : ''; +} + +/** The people each name finds in every contact book, the closest first. */ +export function findPeople(names: string[]) { + return apiRequest('GET', `/api/widgets/people${query({name: names})}`, peopleSchema); +} + +const calendarSchema = z.object({ + connectionId: z.string(), + id: z.string(), + name: z.string(), + primary: z.boolean(), + writable: z.boolean(), + color: z.string().nullable(), +}); + +const agendaEventSchema = z.object({ + connectionId: z.string(), + calendarId: z.string(), + id: z.string(), + title: z.string(), + start: z.string(), + end: z.string(), + allDay: z.boolean(), + location: z.string(), + guests: z.array(z.string()), + editable: z.boolean(), + link: z.string().nullable(), +}); + +const agendaSchema = z.object({ + sources: z.array(sourceSchema), + calendars: z.array(calendarSchema), + events: z.array(agendaEventSchema), + target: z.object({connectionId: z.string(), calendarId: z.string()}).nullable(), + failed: z.array(failureSchema), +}); + +export type AgendaCalendar = z.infer; +export type AgendaEvent = z.infer; +export type Agenda = z.infer; + +/** Every calendar's events between two times, and the calendars a new event can go in. */ +export function getAgenda(from: Date, to: Date) { + return apiRequest( + 'GET', + `/api/widgets/agenda${query({from: from.toISOString(), to: to.toISOString()})}`, + agendaSchema, + ); +} + +const foundEventsSchema = z.object({ + sources: z.array(sourceSchema), + events: z.array(agendaEventSchema), + failed: z.array(failureSchema), +}); + +export type FoundEvents = z.infer; + +export type EventSearch = {from: string; searches: string[]}; + +/** The events from a time on that any of the searches finds in every calendar, soonest first. */ +export function findEvents(search: EventSearch) { + return apiRequest( + 'GET', + `/api/widgets/events${query({from: search.from, search: search.searches})}`, + foundEventsSchema, + ); +} + +export type NewEvent = { + connectionId: string; + calendarId: string; + title: string; + start: string; + end: string; + location: string; + guests: string[]; +}; + +const madeEventSchema = z.object({title: z.string(), link: z.string().nullable()}); + +/** Makes the event; the calendar's provider invites its guests. */ +export function createEvent(event: NewEvent) { + return apiRequest('POST', '/api/widgets/events', madeEventSchema, event); +} + +/** What changes of an event: only what is given, guests as they are to be once it is changed. */ +export type EventChange = { + connectionId: string; + calendarId: string; + eventId: string; + title?: string; + start?: string; + end?: string; + location?: string; + guests?: string[]; +}; + +/** Changes an event the person organizes, its guests told. */ +export function editEvent(change: EventChange) { + return apiRequest('PUT', '/api/widgets/events', madeEventSchema, change); +} + +const foundEmailSchema = z.object({ + connectionId: z.string(), + id: z.string(), + from: z.string(), + subject: z.string(), + date: z.string(), + unread: z.boolean(), + link: z.string().nullable(), +}); + +const foundEmailsSchema = z.object({ + sources: z.array(sourceSchema), + emails: z.array(foundEmailSchema), + failed: z.array(failureSchema), +}); + +export type FoundEmail = z.infer; +export type FoundEmails = z.infer; + +// `words` are terms every one of which is searched for, each its words joined by " | ", any of which +// will do: "flight | itinerary | booking". +export type EmailQuery = {from: string | null; words: string[]; since: string | null}; + +/** The newest emails every mailbox finds: from someone, about the terms, since a day (2026-10-07). */ +export function findEmails(search: EmailQuery) { + return apiRequest( + 'GET', + `/api/widgets/emails${query({from: search.from, words: search.words, since: search.since})}`, + foundEmailsSchema, + ); +} + +const mailboxesSchema = z.object({sources: z.array(sourceSchema), target: z.string().nullable()}); + +export type Mailboxes = z.infer; + +/** The mailboxes email can go out from, and the one last written from. */ +export function getMailboxes() { + return apiRequest('GET', '/api/widgets/mailboxes', mailboxesSchema); +} + +export type NewEmail = { + connectionId: string; + to: string[]; + subject: string; + body: string; + send: boolean; +}; + +/** Sends the email, or saves it as a draft. */ +export function writeEmail(email: NewEmail) { + return apiRequest('POST', '/api/widgets/emails', z.object({sent: z.boolean()}), email); +} + +const foundFileSchema = z.object({ + connectionId: z.string(), + id: z.string(), + name: z.string(), + kind: z.string(), + owner: z.string(), + modified: z.string(), + link: z.string().nullable(), + // Where it has the words searched for: its name, or only inside it. + matched: z.enum(['name', 'content']), +}); + +const foundFilesSchema = z.object({ + sources: z.array(sourceSchema), + files: z.array(foundFileSchema), + failed: z.array(failureSchema), +}); + +export type FoundFile = z.infer; +export type FoundFiles = z.infer; + +export type FileKind = 'Document' | 'Spreadsheet' | 'Presentation' | 'Folder'; + +export type FileQuery = {words: string[]; kind: FileKind | null; owner: string | null}; + +/** The files every file store finds: words of the name, a kind, an owner's address. */ +export function findFiles(search: FileQuery) { + return apiRequest( + 'GET', + `/api/widgets/files${query({words: search.words, kind: search.kind, owner: search.owner})}`, + foundFilesSchema, + ); +} + +const ratesSchema = z.object({date: z.string(), rates: z.record(z.string(), z.number())}); + +export type DayRates = z.infer; + +/** What one euro is worth in each currency today, the European Central Bank's, by code. */ +export function getRates() { + return apiRequest('GET', '/api/widgets/rates', ratesSchema); +} diff --git a/packages/web/src/components/activity-graph.tsx b/packages/web/src/components/activity-graph.tsx new file mode 100644 index 00000000..078370d8 --- /dev/null +++ b/packages/web/src/components/activity-graph.tsx @@ -0,0 +1,202 @@ +import {useRef, useState} from 'react'; +import {Link} from 'react-router'; +import type {ActivitySummary} from '@/client/schemas'; +import {agentName, formatDay, graphWeeks, summarizeWeek, type GraphDay} from '@/lib/activity-graph'; +import {counted} from '@/lib/format'; +import type {AgentLogin, Connection} from '@/lib/types'; +import {cn} from '@proxy/ui/lib/utils'; + +// A year on a wide screen, half of one on a phone. +const WEEKS = 52; +const PHONE_WEEKS = 26; + +// One hue, the text's, darker the busier the day in light mode and lighter in dark, a day without +// any a faint square. +const LEVELS = [ + 'bg-foreground/[0.07]', + 'bg-foreground/20', + 'bg-foreground/40', + 'bg-foreground/65', + 'bg-foreground/90', +]; + +const ROW_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', '']; + +const CELL = 'size-2.5 rounded-[2px]'; + +// About half a day's tooltip, so one at either end of the grid stays over it. +const TOOLTIP_HALF = 72; + +// The month a week's column is labelled with: the one starting in it. +function monthStarting(week: Array): string | null { + const first = week.find((cell) => cell?.day.endsWith('-01')); + if (!first) { + return null; + } + return new Date(`${first.day}T00:00`).toLocaleDateString('en-US', {month: 'short'}); +} + +type Hovered = {cell: GraphDay; left: number; top: number}; + +// Under the home page's input: what the agents, and the person from the home page, did each day of +// the past year, and the past week in a line, or with nothing done yet, that it will show here. +// Each busy day opens its part of the activity log. +export function ActivityGraph({ + summary, + agents, + connections, +}: { + summary: ActivitySummary; + agents: AgentLogin[]; + connections: Connection[]; +}) { + const frame = useRef(null); + const [hovered, setHovered] = useState(null); + const weeks = graphWeeks(summary, WEEKS); + const week = summarizeWeek(summary.week, connections); + const show = (cell: GraphDay, target: HTMLElement) => { + const outer = frame.current?.getBoundingClientRect(); + const box = target.getBoundingClientRect(); + if (!outer) { + return; + } + // Kept clear of the edges, where half a tooltip would hang off a phone's screen. + const middle = box.left - outer.left + box.width / 2; + const left = Math.min(Math.max(middle, TOOLTIP_HALF), outer.width - TOOLTIP_HALF); + setHovered({cell, left, top: box.top - outer.top}); + }; + + return ( +
+
+
setHovered(null)}> +
+ {ROW_LABELS.map((label, row) => ( + + {label} + + ))} +
+ {weeks.map((column, index) => ( +
+ + {monthStarting(column)} + + {column.map((cell, weekday) => ( + setHovered(null)} + /> + ))} +
+ ))} + {hovered && } +
+
+ Less + {LEVELS.map((color) => ( + + ))} + More +
+
+ {summary.days.length === 0 && ( +

+ Your agents’ activity will show up here +

+ )} + {summary.days.length > 0 && ( +

+ {week.total === 0 ? 'No requests' : counted(week.total, 'request')} in the past week + {week.done.length > 0 && `: ${week.done.map(lowerFirst).join(', ')}`} + {week.denied > 0 && ( + <> + {' · '} + + {week.denied} blocked + + + )} +

+ )} +
+ ); +} + +function lowerFirst(text: string): string { + return text.charAt(0).toLowerCase() + text.slice(1); +} + +function Cell({ + cell, + onShow, + onHide, +}: { + cell: GraphDay | null; + onShow: (cell: GraphDay, target: HTMLElement) => void; + onHide: () => void; +}) { + if (!cell) { + return ; + } + + const color = LEVELS[cell.level]; + if (cell.count === 0) { + return ( + onShow(cell, event.currentTarget)} + /> + ); + } + + return ( + onShow(cell, event.currentTarget)} + onFocus={(event) => onShow(cell, event.currentTarget)} + onBlur={onHide} + className={cn( + CELL, + color, + 'outline-offset-1 transition-shadow hover:ring-1 hover:ring-foreground focus-visible:outline-2 focus-visible:outline-ring', + )} + /> + ); +} + +// What a day came to: its count first, then the day and each agent's part, and what was refused. +function DayTooltip({hovered, agents}: {hovered: Hovered; agents: AgentLogin[]}) { + const {cell} = hovered; + return ( +
+ + {counted(cell.count, 'request')} + · {formatDay(cell.day)} + + {cell.agents.map((agent) => ( + + {agentName(agent.agentId, agents)} + {agent.count.toLocaleString('en-US')} + + ))} + {cell.denied > 0 && {cell.denied} blocked} +
+ ); +} diff --git a/packages/web/src/components/animated-height.tsx b/packages/web/src/components/animated-height.tsx new file mode 100644 index 00000000..fb01d060 --- /dev/null +++ b/packages/web/src/components/animated-height.tsx @@ -0,0 +1,29 @@ +import {useEffect, useRef, useState, type ReactNode} from 'react'; + +// Grows and shrinks to fit what is in it, smoothly, and folds away when closed: the height CSS can't +// animate to `auto`, so it is measured as the content changes. Closed, it keeps what it showed, so +// folding away doesn't empty it first, and makes it inert. +export function AnimatedHeight({open, children}: {open: boolean; children: ReactNode}) { + const inner = useRef(null); + const [height, setHeight] = useState(0); + + useEffect(() => { + const element = inner.current; + if (!element) { + return; + } + const observer = new ResizeObserver(() => setHeight(element.offsetHeight)); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + return ( +
+
{children}
+
+ ); +} diff --git a/packages/web/src/components/app-shell.tsx b/packages/web/src/components/app-shell.tsx index 1579f4c6..c4707a92 100644 --- a/packages/web/src/components/app-shell.tsx +++ b/packages/web/src/components/app-shell.tsx @@ -5,7 +5,9 @@ import {useDesktop} from '@proxy/ui/hooks/use-media-query'; type Props = { children: ReactNode; - title: ReactNode; + // The header's title; a page without one, as Home, has no header, and on a phone the sidebar's + // button alone in its corner. + title?: ReactNode; actions?: ReactNode; }; @@ -18,13 +20,20 @@ export function AppShell({children, title, actions}: Props) { <>
-
-
- {!desktop && } - {title} + {title !== undefined && ( +
+
+ {!desktop && } + {title} +
+ {actions &&
{actions}
} +
+ )} + {title === undefined && !desktop && ( +
+
- {actions &&
{actions}
} -
+ )}
{children}
diff --git a/packages/web/src/components/audit-entry-dialog.tsx b/packages/web/src/components/audit-entry-dialog.tsx index 5e31c1ea..fc4c5903 100644 --- a/packages/web/src/components/audit-entry-dialog.tsx +++ b/packages/web/src/components/audit-entry-dialog.tsx @@ -9,7 +9,7 @@ import { DialogHeader, DialogTitle, } from '@proxy/ui/components/dialog'; -import {AgentLogo, IntegrationLogo} from '@/components/brand-logo'; +import {AgentLogo, IntegrationLogo, ProxyLogo} from '@/components/brand-logo'; import {describeEntry, entryAction, locateEntry} from '@/lib/access'; import type {AgentLogin, AuditEntry, Connection} from '@/lib/types'; @@ -91,14 +91,21 @@ function AuditEntryContent({ )} - {agent ? ( + {entry.agentId === null && ( + <> + + Personal Agent Proxy + + )} + {entry.agentId !== null && agent && ( <> {agent.name} - ) : ( + )} + {entry.agentId !== null && !agent && ( Deleted agent )} diff --git a/packages/web/src/components/audit-list.tsx b/packages/web/src/components/audit-list.tsx index c1ca29a4..9b6915dc 100644 --- a/packages/web/src/components/audit-list.tsx +++ b/packages/web/src/components/audit-list.tsx @@ -1,7 +1,7 @@ import {useState} from 'react'; import {AuditEntryDialog} from '@/components/audit-entry-dialog'; import {EmptyRows, RowHeader, RowList} from '@/components/row-list'; -import {AgentLogo, IntegrationLogo} from '@/components/brand-logo'; +import {AgentLogo, IntegrationLogo, ProxyLogo} from '@/components/brand-logo'; import {connectionLabel, describeEntry, locateEntry} from '@/lib/access'; import {formatDateTime} from '@/lib/format'; import type {AgentLogin, AuditEntry, Connection} from '@/lib/types'; @@ -27,6 +27,20 @@ function NamedLogo({name, logo}: {name: string; logo: ReactNode}) { ); } +// The agent an entry was made by, or Personal Agent Proxy for what it did itself. +function EntryAgentLogo({agentId, agent}: {agentId: string | null; agent: AgentLogin | undefined}) { + if (agentId === null) { + return } />; + } + + return ( + } + /> + ); +} + type Props = { entries: AuditEntry[]; // To name each entry's agent and connection, while they still exist. @@ -39,7 +53,8 @@ type Props = { // The audit log's lines: when, which agent, what it did, and where, the agent and the connection // each a logo with its name in a tooltip. Denied requests read in red; those that came in over MCP -// say so. A row opens the entry in full. +// say so. What Personal Agent Proxy did itself, as the home page's widgets write, reads as its own. +// A row opens the entry in full. export function AuditList({ entries, agents, @@ -75,12 +90,7 @@ export function AuditList({ className="flex h-10 w-full items-center gap-3 px-4 text-left text-sm hover:bg-muted/50 active:bg-muted/50 md:px-3" > {formatDateTime(entry.at)} - {showAgent && ( - } - /> - )} + {showAgent && } {describeEntry(entry, located)} {entry.via === 'mcp' && ( diff --git a/packages/web/src/components/brand-logo.tsx b/packages/web/src/components/brand-logo.tsx index ecb3f912..f3ea36bf 100644 --- a/packages/web/src/components/brand-logo.tsx +++ b/packages/web/src/components/brand-logo.tsx @@ -21,18 +21,18 @@ function LogoTile({children, className}: {children: ReactNode; className?: strin } // A custom agent has no logo of its own, so it gets a robot. -export function AgentLogo({providerId}: {providerId: string}) { +export function AgentLogo({providerId, className}: {providerId: string; className?: string}) { const provider = findAgentProvider(providerId); if (provider?.custom) { return ( - + ); } return ( - + {provider?.faviconUrl && ( )} @@ -40,6 +40,15 @@ export function AgentLogo({providerId}: {providerId: string}) { ); } +// Personal Agent Proxy's own mark, for what it did itself in the activity log. +export function ProxyLogo() { + return ( + + + + ); +} + export function IntegrationLogo({ integration, className, diff --git a/packages/web/src/components/connected-logos.tsx b/packages/web/src/components/connected-logos.tsx new file mode 100644 index 00000000..cdc00ad0 --- /dev/null +++ b/packages/web/src/components/connected-logos.tsx @@ -0,0 +1,101 @@ +import {Tooltip, TooltipContent, TooltipTrigger} from '@proxy/ui/components/tooltip'; +import type {ReactNode} from 'react'; +import {Link} from 'react-router'; +import {AgentLogo, IntegrationLogo} from '@/components/brand-logo'; +import {connectionLabel} from '@/lib/access'; +import {counted} from '@/lib/format'; +import {integrationOf, type Integration} from '@/lib/integrations'; +import type {AgentLogin, Connection} from '@/lib/types'; + +const TILE = 'size-6 rounded-md ring-2 ring-background'; + +// The logos a stack shows at most; the count beside it says how many there are. +const SHOWN = 8; + +// The connections as the stack shows them: one logo per integration, its connections behind it, +// Information left out, since every organization has it. A custom MCP server is its own. +function sourcesOf(connections: Connection[]): Array<{integration: Integration; names: string[]}> { + const sources: Array<{integration: Integration; names: string[]}> = []; + for (const connection of connections) { + const integration = integrationOf(connection); + if (!integration || integration.builtIn) { + continue; + } + const name = connectionLabel(connections, connection); + const known = sources.find( + (source) => source.integration.id === integration.id && !integration.custom, + ); + if (known) { + known.names.push(name); + continue; + } + sources.push({integration, names: [name]}); + } + return sources; +} + +// Under the home page's input: the sources and the agents connected, each a stack of logos with +// its name on hover, leading to their pages; with none yet, a link to connect one. +export function ConnectedLogos({ + connections, + agents, +}: { + connections: Connection[]; + agents: AgentLogin[]; +}) { + const sources = sourcesOf(connections); + const sourceCount = sources.reduce((count, source) => count + source.names.length, 0); + const active = agents.filter((agent) => agent.revokedAt === null); + + return ( +
+ 0 ? '/connections' : '/connections/new'} + label={sourceCount > 0 ? counted(sourceCount, 'source') : 'Connect a source'} + > + {sources.slice(0, SHOWN).map(({integration, names}) => ( + 1 + ? `${integration.name} · ${names.length} accounts` + : (names[0] ?? integration.name) + } + > + + + ))} + + 0 ? '/agents' : '/agents/new'} + label={active.length > 0 ? counted(active.length, 'agent') : 'Add an agent'} + > + {active.slice(0, SHOWN).map((agent) => ( + + + + ))} + +
+ ); +} + +function Group({to, label, children}: {to: string; label: string; children: ReactNode[]}) { + return ( + + {children.length > 0 && {children}} + {label} + + ); +} + +function Logo({name, children}: {name: string; children: ReactNode}) { + return ( + + }> + {children} + + {name} + + ); +} diff --git a/packages/web/src/components/nav.tsx b/packages/web/src/components/nav.tsx index 70bc4d0f..12676640 100644 --- a/packages/web/src/components/nav.tsx +++ b/packages/web/src/components/nav.tsx @@ -1,5 +1,6 @@ import { BotIcon, + HomeIcon, IdCardIcon, LogOutIcon, PlugIcon, @@ -25,12 +26,19 @@ import { type Destination = {to: string; label: string; icon: LucideIcon}; const DESTINATIONS: Destination[] = [ + {to: '/', label: 'Home', icon: HomeIcon}, {to: '/connections', label: 'Connections', icon: PlugIcon}, {to: '/info', label: 'Information', icon: IdCardIcon}, {to: '/agents', label: 'Agents', icon: BotIcon}, {to: '/activity', label: 'Activity', icon: ScrollTextIcon}, ]; +// Each destination is lit on its own pages too, as `/connections/:id`; Home only on itself, since +// every path starts with `/`. +function isActive(pathname: string, to: string): boolean { + return to === '/' ? pathname === '/' : pathname.startsWith(to); +} + // The stock shadcn sidebar: a rail in icon mode on desktop, labels in tooltips and ⌘B to expand // it; a sheet from the left on phones, opened from the header and closed again on every pick. export function AppSidebar() { @@ -46,7 +54,7 @@ export function AppSidebar() { {DESTINATIONS.map((destination) => ( setOpenMobile(false)} />} > diff --git a/packages/web/src/components/widgets/calendar-widget.tsx b/packages/web/src/components/widgets/calendar-widget.tsx new file mode 100644 index 00000000..35f9637a --- /dev/null +++ b/packages/web/src/components/widgets/calendar-widget.tsx @@ -0,0 +1,164 @@ +import {CalendarDaysIcon} from 'lucide-react'; +import {useState} from 'react'; +import type {Intent} from '@/client/intent-client'; +import type {AgendaEvent, Failure} from '@/client/widgets-client'; +import {ConnectWidget} from '@/components/widgets/connect-widget'; +import {DayGrid} from '@/components/widgets/day-grid'; +import {dayAround, dayBounds, eventKey, searchFrom, timedItems} from '@/lib/calendar-day'; +import {Missing, WidgetFrame} from '@/components/widgets/widget-frame'; +import {useAgenda, useEvents, usePeople} from '@/lib/widget-lookups'; +import { + dayAsked, + eventSearches, + fieldTexts, + isAddress, + namesIn, + personOnEvent, +} from '@/lib/widget-values'; + +const DAY = new Intl.DateTimeFormat(undefined, {weekday: 'short', month: 'short', day: 'numeric'}); +const TIME = new Intl.DateTimeFormat(undefined, {hour: 'numeric', minute: '2-digit'}); +// How many more of the events found it offers beside the one it shows. +const MORE = 4; + +// "Today", "Tomorrow", else the day: "Fri, Oct 17". +function dayName(day: Date, now: Date): string { + const offset = Math.round( + (dayBounds(day).start.getTime() - dayBounds(now).start.getTime()) / 86_400_000, + ); + const relative = {[-1]: 'Yesterday', 0: 'Today', 1: 'Tomorrow'}[offset]; + return relative ? `${relative} · ${DAY.format(day)}` : DAY.format(day); +} + +// When an event is: "Tue, Oct 21 · 3:00 PM", or its day alone for one all day. +function whenOf(event: AgendaEvent): string { + const start = new Date(event.start); + if (event.allDay) { + return DAY.format(new Date(`${event.start}T00:00`)); + } + return `${DAY.format(start)} · ${TIME.format(start)}`; +} + +// An all-day event's day on the person's clock: Google writes it as the day alone. +function startDay(event: AgendaEvent): Date { + return event.allDay ? new Date(`${event.start}T00:00`) : new Date(event.start); +} + +function eachConnection(failures: Failure[]): Failure[] { + return [...new Map(failures.map((failure) => [failure.connectionId, failure])).values()]; +} + +// Calendar: a day of every calendar, the one asked about or today; or, asked when something is, +// the day the soonest event the calendars find for it is on, with it highlighted and the next +// few a click away. +export function CalendarWidget({intent, typed, now}: {intent: Intent; typed: string; now: Date}) { + const names = namesIn(intent, 'meetingWith'); + const [what] = fieldTexts(intent, 'eventName'); + const searching = names.length > 0 || Boolean(what); + const asked = dayAsked(typed, now); + const [first] = names; + // The person is looked up first, so their addresses are searched for too. + const lookFor = first && !isAddress(first) ? [first] : []; + const people = usePeople(lookFor.length > 0 ? lookFor : null); + const searches = eventSearches({name: first, what}, people.data); + const ready = searching && searches.length > 0 && (lookFor.length === 0 || !people.loading); + const found = useEvents( + ready ? {from: searchFrom(asked, now).toISOString(), searches} : null, + ); + const [picked, setPicked] = useState(null); + + const matches = found.data?.events ?? []; + const shown = matches.find((event) => eventKey(event) === picked) ?? matches[0] ?? null; + const day = shown ? startDay(shown) : (asked ?? now); + // Searching, the day waits for what is found, or shows the day asked about when nothing is. + const showsDay = !searching || shown !== null || (found.data !== null && asked !== null); + const agenda = useAgenda(showsDay ? dayAround(day) : null); + + if (agenda.data?.sources.length === 0 || found.data?.sources.length === 0) { + return ( + + Connect a calendar to see your days and find your meetings from here. + + ); + } + + const highlighted = shown ? eventKey(shown) : null; + // The event found is on its day whatever the day's read says, and before it is read. + const read = agenda.data?.events ?? []; + const events = + shown && !read.some((event) => eventKey(event) === highlighted) ? [...read, shown] : read; + const items = timedItems(events, day, highlighted); + const allDay = events.filter((event) => event.allDay); + // Named as the person on the event found, never a namesake; else as written. + const who = first + ? ((shown && personOnEvent(people.data, first, shown.guests)?.name) ?? first) + : null; + const described = [...(who ? [`with ${who}`] : []), ...(what ? [what] : [])].join(' · '); + const others = matches.filter((event) => event !== shown).slice(0, MORE); + const failed = eachConnection([...(agenda.data?.failed ?? []), ...(found.data?.failed ?? [])]); + + return ( + + {searching && ( +

+ {what && !who ? 'Next ' : 'Next event '} + {described} + {shown ? ( + · {whenOf(shown)} + ) : ( + + {' '} + · {!found.data || found.loading ? 'searching…' : 'nothing coming up'} + + )} +

+ )} + {showsDay && ( +
+

+ {dayName(day, now)} + + {!agenda.data && agenda.loading + ? 'Reading your calendar…' + : events.length === 0 + ? 'Nothing on your calendar' + : `${events.length} ${events.length === 1 ? 'event' : 'events'}`} + +

+ {allDay.length > 0 && ( +

+ All day:{' '} + {allDay.map((event, index) => ( + + {index > 0 && ', '} + + {event.title} + + + ))} +

+ )} + +
+ )} + {others.length > 0 && ( +
    + {others.map((event) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/packages/web/src/components/widgets/connect-widget.tsx b/packages/web/src/components/widgets/connect-widget.tsx new file mode 100644 index 00000000..f28714fa --- /dev/null +++ b/packages/web/src/components/widgets/connect-widget.tsx @@ -0,0 +1,60 @@ +import type {LucideIcon} from 'lucide-react'; +import {Link} from 'react-router'; +import {buttonVariants} from '@proxy/ui/components/button'; +import {IntegrationLogo} from '@/components/brand-logo'; +import {WidgetFrame} from '@/components/widgets/widget-frame'; +import {INTEGRATIONS, signInPath} from '@/lib/integrations'; + +type Props = { + icon: LucideIcon; + name: string; + // The collection the widget reads, as the catalog names it: `events`. + collectionId: string; + // What it asks for and why: "Connect a calendar to see your day and send invites from here." + children: string; +}; + +// A widget with nothing connected to read: what it needs, and a button per integration that has +// it, which signs in and comes back to the home page, or opens the integration's form. +export function ConnectWidget({icon, name, collectionId, children}: Props) { + const offering = INTEGRATIONS.filter( + (integration) => + !integration.builtIn && + integration.collections.some((collection) => collection.id === collectionId), + ); + + return ( + { + const className = buttonVariants({size: 'sm', variant: 'outline'}); + const content = ( + <> + + Connect {integration.name} + + ); + const signIn = signInPath(integration.id, {returnTo: 'home'}); + if (signIn) { + return ( + + {content} + + ); + } + return ( + + {content} + + ); + })} + > +

{children}

+
+ ); +} diff --git a/packages/web/src/components/widgets/convert-widget.tsx b/packages/web/src/components/widgets/convert-widget.tsx new file mode 100644 index 00000000..8a8150db --- /dev/null +++ b/packages/web/src/components/widgets/convert-widget.tsx @@ -0,0 +1,119 @@ +import {findUnit, UNITS, convert, type Rates, type Unit} from '@proxy/utils/units'; +import {ArrowRightLeftIcon} from 'lucide-react'; +import type {Intent} from '@/client/intent-client'; +import {CopyButton} from '@/components/copy-button'; +import {Missing, WidgetFrame} from '@/components/widgets/widget-frame'; +import {formatNumber} from '@/lib/calculate'; +import {useRates} from '@/lib/widget-lookups'; +import {fieldTexts} from '@/lib/widget-values'; + +// The other units of a kind shown under the answer, at most, and the amounts they read well at: +// 5 miles is 8,046.72 m, not 8,046,720 mm. +const ALSO = 4; +const READABLE = {from: 0.01, to: 100_000}; + +// The currencies shown under a conversion of money, those of them it isn't already in. +const ALSO_MONEY = ['usd', 'eur', 'gbp', 'jpy', 'chf', 'cad']; + +// A conversion's significant digits: 72 °F is 22.2222 °C. +const DIGITS = 6; + +const DAY = new Intl.DateTimeFormat(undefined, {month: 'short', day: 'numeric'}); + +// An amount as the answer reads, "8.04672 kilometres", or as the others under it do, "8,046.72 +// m". A temperature is always its symbol, "22.2222 °C", and money its currency's, "€50.02". +function amountOf(value: number, unit: Unit, short = false): string { + if (unit.kind === 'currency') { + return new Intl.NumberFormat(undefined, {style: 'currency', currency: unit.symbol}).format( + value, + ); + } + if (short || unit.kind === 'temperature') { + return `${formatNumber(value, DIGITS)} ${unit.symbol}`; + } + return `${formatNumber(value, DIGITS)} ${value === 1 ? unit.name : unit.plural}`; +} + +// The same amount in a few other units of its kind, each where it reads well. +function othersOf(amount: number, from: Unit, to: Unit, rates: Rates | undefined) { + const candidates = + from.kind === 'currency' + ? ALSO_MONEY.flatMap((id) => findUnit(id) ?? []) + : UNITS.filter((unit) => unit.kind === from.kind); + return candidates + .flatMap((unit) => { + const value = + unit.id === from.id || unit.id === to.id ? null : convert(amount, from, unit, rates); + const readable = + value !== null && + (from.kind === 'currency' || + (Math.abs(value) >= READABLE.from && Math.abs(value) <= READABLE.to)); + return readable ? [{unit, value}] : []; + }) + .slice(0, ALSO); +} + +// Convert units: the amount in the unit asked for, or, where none was, its metric or imperial +// counterpart (a dollar's the euro, other money's the dollar), with the same in a few other units of +// its kind. Money is at the European Central Bank's rates of the day. +export function ConvertWidget({intent}: {intent: Intent}) { + const [amountText] = fieldTexts(intent, 'amount'); + const from = findUnit(fieldTexts(intent, 'fromUnit')[0] ?? ''); + const asked = findUnit(fieldTexts(intent, 'toUnit')[0] ?? ''); + const to = + asked && asked.kind === from?.kind && asked.id !== from.id + ? asked + : from && findUnit(from.counterpart); + const money = from?.kind === 'currency'; + const rates = useRates(money ? true : null); + const amount = amountText === undefined ? 1 : Number(amountText.replace(/,/g, '')); + const result = + from && to && Number.isFinite(amount) ? convert(amount, from, to, rates.data?.rates) : null; + + return ( + + {!from && Type an amount and its unit, such as 5 miles in km} + {money && result === null && ( + + {rates.error ? 'Today’s exchange rates couldn’t be fetched' : 'Fetching today’s rates…'} + + )} + {from && to && result !== null && ( + <> +
+
+

{amountOf(amount, from)} =

+

+ {amountOf(result, to)} +

+
+ +
+ + {money && rates.data && ( +

+ European Central Bank rates of {DAY.format(new Date(`${rates.data.date}T00:00`))} +

+ )} + + )} +
+ ); +} + +function Others({amount, from, to, rates}: {amount: number; from: Unit; to: Unit; rates?: Rates}) { + const others = othersOf(amount, from, to, rates); + if (others.length === 0) { + return null; + } + return ( +

+ {others.map(({unit, value}) => ( + {amountOf(value, unit, true)} + ))} +

+ ); +} diff --git a/packages/web/src/components/widgets/day-grid.tsx b/packages/web/src/components/widgets/day-grid.tsx new file mode 100644 index 00000000..baffdb3c --- /dev/null +++ b/packages/web/src/components/widgets/day-grid.tsx @@ -0,0 +1,99 @@ +import type {DayItem} from '@/lib/calendar-day'; + +const HOUR_PX = 28; +const TIME = new Intl.DateTimeFormat(undefined, {hour: 'numeric', minute: '2-digit'}); +// Where the events start, past the hours written down the side (`left-16`). +const GUTTER = '4rem'; + +function hours(date: Date): number { + return date.getHours() + date.getMinutes() / 60; +} + +type Placed = DayItem & {lane: number; lanes: number}; + +// Events at the same time side by side: each in the first lane free when it starts, every event +// of a run that overlaps as wide as the run's lanes allow. +function placed(items: DayItem[]): Placed[] { + const sorted = [...items].sort( + (a, b) => a.start.getTime() - b.start.getTime() || b.end.getTime() - a.end.getTime(), + ); + const done: Placed[] = []; + let run: Array = []; + let runEnd = 0; + const close = () => { + const lanes = Math.max(1, ...run.map((item) => item.lane + 1)); + done.push(...run.map((item) => ({...item, lanes}))); + run = []; + }; + for (const item of sorted) { + if (run.length > 0 && item.start.getTime() >= runEnd) { + close(); + } + const taken = run.filter((other) => other.end > item.start).map((other) => other.lane); + let lane = 0; + while (taken.includes(lane)) { + lane += 1; + } + run.push({...item, lane}); + runEnd = Math.max(runEnd, item.end.getTime()); + } + close(); + return done; +} + +// A day from 9 to 6, widened to take in its events, the hours down the side and the events beside +// them, the one highlighted in the primary colour, each opening where it is. +export function DayGrid({items}: {items: DayItem[]}) { + const from = Math.min(9, ...items.map((item) => Math.floor(hours(item.start)))); + const to = Math.max(18, ...items.map((item) => Math.ceil(hours(item.end)))); + const top = (date: Date) => (hours(date) - from) * HOUR_PX; + const height = (start: Date, end: Date) => Math.max((hours(end) - hours(start)) * HOUR_PX, 18); + + return ( +
+ {Array.from({length: to - from}, (_, index) => ( +
+ {TIME.format(new Date(2000, 0, 1, from + index))} +
+ ))} + {placed(items).map((item) => { + const style = { + top: top(item.start), + height: height(item.start, item.end), + left: `calc(${GUTTER} + (100% - ${GUTTER}) * ${item.lane} / ${item.lanes})`, + width: `calc((100% - ${GUTTER}) / ${item.lanes} - 2px)`, + }; + const className = `absolute truncate rounded-md px-2 ${item.highlighted ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}`; + if (item.link) { + return ( + + {item.title} + + ); + } + return ( +
+ {item.title} +
+ ); + })} +
+ ); +} diff --git a/packages/web/src/components/widgets/edit-event-widget.tsx b/packages/web/src/components/widgets/edit-event-widget.tsx new file mode 100644 index 00000000..b4eb33aa --- /dev/null +++ b/packages/web/src/components/widgets/edit-event-widget.tsx @@ -0,0 +1,253 @@ +import {CalendarIcon, ClockIcon, MapPinIcon} from 'lucide-react'; +import {useState} from 'react'; +import type {Intent} from '@/client/intent-client'; +import {editEvent, type AgendaEvent, type EventChange} from '@/client/widgets-client'; +import {Button} from '@proxy/ui/components/button'; +import {Input} from '@proxy/ui/components/input'; +import {ConnectWidget} from '@/components/widgets/connect-widget'; +import {DayAgenda, GuestRow} from '@/components/widgets/event-widget'; +import {Avatar, DetailRow, Missing, WidgetFrame} from '@/components/widgets/widget-frame'; +import {WidgetSelect} from '@/components/widgets/widget-select'; +import {WriteResult} from '@/components/widgets/write-result'; +import {dayAround, eventKey, searchFrom} from '@/lib/calendar-day'; +import {useAgenda, useEvents, usePeople} from '@/lib/widget-lookups'; +import { + dayAsked, + eventSearches, + fieldTexts, + guestsNamed, + guestsOf, + isAddress, + movedTime, + namesIn, +} from '@/lib/widget-values'; +import {useSuggested} from '@/lib/use-suggested'; +import {useWriteStatus, writeErrorMessage} from '@/lib/write-status'; + +const TIME = new Intl.DateTimeFormat(undefined, {hour: 'numeric', minute: '2-digit'}); +const DAY = new Intl.DateTimeFormat(undefined, {weekday: 'short', month: 'short', day: 'numeric'}); + +type Span = {start: Date; end: Date}; + +function spanText({start, end}: Span): string { + return `${DAY.format(start)} · ${TIME.format(start)} – ${TIME.format(end)}`; +} + +function sameSpan(a: Span, b: Span): boolean { + return a.start.getTime() === b.start.getTime() && a.end.getTime() === b.end.getTime(); +} + +function optionLabel(event: AgendaEvent): string { + return event.allDay + ? `${event.title} · ${DAY.format(new Date(`${event.start}T00:00`))}` + : `${event.title} · ${spanText({start: new Date(event.start), end: new Date(event.end)})}`; +} + +// Change event: the event meant, found as the Calendar widget finds one, the first the person can +// change unless they pick another; what changes of it, its time, title, place and guests, each +// beside what it was; and the day it moves to, with what it would clash with. +export function EditEventWidget({intent, typed, now}: {intent: Intent; typed: string; now: Date}) { + const [name] = namesIn(intent, 'changeWith'); + const [what] = fieldTexts(intent, 'changeEvent'); + const [when] = fieldTexts(intent, 'changeFrom'); + const [to] = fieldTexts(intent, 'changeTo'); + const [newTitle] = fieldTexts(intent, 'newTitle'); + const [newLocation] = fieldTexts(intent, 'newLocation'); + const inviting = namesIn(intent, 'invite'); + const uninviting = namesIn(intent, 'uninvite'); + + const lookFor = [ + ...new Set([name, ...inviting, ...uninviting].filter((one): one is string => Boolean(one))), + ].filter((one) => !isAddress(one)); + const people = usePeople(lookFor.length > 0 ? lookFor : null); + const searches = eventSearches({name, what}, people.data); + const from = searchFrom(when ? dayAsked(when, now) : null, now); + const found = useEvents( + searches.length > 0 && (lookFor.length === 0 || !people.loading) + ? {from: from.toISOString(), searches} + : null, + ); + const [picked, setPicked] = useState(null); + const [chosen, setChosen] = useState>({}); + const [status, setStatus] = useWriteStatus(typed); + + const candidates = found.data?.events ?? []; + const event = + candidates.find((one) => eventKey(one) === picked) ?? + candidates.find((one) => one.editable) ?? + candidates[0] ?? + null; + const was = + event && !event.allDay ? {start: new Date(event.start), end: new Date(event.end)} : null; + const moved = was && to ? movedTime(to, was, now) : null; + const time = moved ?? was; + const agenda = useAgenda(time ? dayAround(time.start) : null); + const [title, setTitle] = useSuggested(newTitle ?? event?.title ?? ''); + + if (found.data?.sources.length === 0) { + return ( + + Connect a calendar to change your events from here. + + ); + } + + const invited = guestsOf(inviting, people.data, chosen); + const had = event?.guests ?? []; + const removed = new Set(uninviting.flatMap((one) => guestsNamed(had, one, people.data))); + const added = invited.flatMap((guest) => + guest.address && !had.includes(guest.address) ? [guest.address] : [], + ); + const guests = [...had.filter((guest) => !removed.has(guest)), ...added]; + const location = newLocation ?? event?.location ?? ''; + + const change: Omit = { + ...(event && title.trim() !== '' && title.trim() !== event.title ? {title: title.trim()} : {}), + ...(moved && was && !sameSpan(moved, was) + ? {start: moved.start.toISOString(), end: moved.end.toISOString()} + : {}), + ...(event && location !== event.location ? {location} : {}), + ...(removed.size > 0 || added.length > 0 ? {guests} : {}), + }; + const changes = Object.keys(change).length; + + const save = () => { + if (!event || changes === 0) { + return; + } + setStatus({kind: 'saving'}); + void editEvent({ + connectionId: event.connectionId, + calendarId: event.calendarId, + eventId: event.id, + ...change, + }).then((result) => { + if (result.isErr()) { + setStatus({kind: 'failed', message: writeErrorMessage(result.error)}); + return; + } + useAgenda.clear(); + useEvents.clear(); + setStatus({ + kind: 'done', + message: guests.length > 0 || had.length > 0 ? 'Update sent' : 'Event changed', + link: result.value.link, + }); + }); + }; + + const searching = searches.length > 0 && (!found.data || found.loading); + const notice = !event + ? null + : !event.editable + ? 'Only events you organize can be changed here' + : event.allDay && to + ? 'All-day events can’t be moved here' + : to && !moved + ? `Couldn’t read “${to}” as a time` + : null; + + return ( + {notice}
: } + actions={ + + } + > + {searches.length === 0 ? ( + Say which event, such as “move my 1:1 with Arne to Thursday at 3”. + ) : !event ? ( + {searching ? 'Looking for the event…' : 'No event coming up matches.'} + ) : ( + <> + {candidates.length > 1 && ( + + ({value: eventKey(one), label: optionLabel(one)}))} + value={eventKey(event)} + onChange={setPicked} + /> + + )} + setTitle(change.target.value)} + /> +
+
+ + {!time ? ( + All day + ) : moved && was && !sameSpan(moved, was) ? ( + <> + {spanText(was)} +
+ {spanText(moved)} + + ) : ( + spanText(time) + )} +
+ + {event.location !== location && event.location !== '' && ( + {event.location} + )} + {location || No location} + +
    + {had.map((guest) => ( +
  • + + + {guest} + + {removed.has(guest) && ( + · taken off + )} +
  • + ))} + {invited + .filter((guest) => !guest.address || !had.includes(guest.address)) + .map((guest) => ( + setChosen({...chosen, [guest.name]: key})} + /> + ))} + {had.length === 0 && invited.length === 0 && ( +
  • No guests
  • + )} +
+
+ {time && ( + + )} +
+ + )} + + ); +} diff --git a/packages/web/src/components/widgets/event-widget.tsx b/packages/web/src/components/widgets/event-widget.tsx new file mode 100644 index 00000000..a9eb389c --- /dev/null +++ b/packages/web/src/components/widgets/event-widget.tsx @@ -0,0 +1,260 @@ +import {CalendarIcon, ClockIcon, MapPinIcon} from 'lucide-react'; +import type {Intent} from '@/client/intent-client'; +import {createEvent, type Agenda} from '@/client/widgets-client'; +import {Button} from '@proxy/ui/components/button'; +import {Input} from '@proxy/ui/components/input'; +import {ConnectWidget} from '@/components/widgets/connect-widget'; +import {DayGrid} from '@/components/widgets/day-grid'; +import {dayAround, timedItems, type DayItem} from '@/lib/calendar-day'; +import {Avatar, DetailRow, Missing, WidgetFrame} from '@/components/widgets/widget-frame'; +import {WidgetSelect} from '@/components/widgets/widget-select'; +import {WriteResult} from '@/components/widgets/write-result'; +import {useAgenda, usePeople} from '@/lib/widget-lookups'; +import {eventTime, fieldTexts, guestsOf, namesIn, personKey, type Guest} from '@/lib/widget-values'; +import {useSuggested} from '@/lib/use-suggested'; +import {useWriteStatus, writeErrorMessage} from '@/lib/write-status'; +import {useState} from 'react'; + +const TIME = new Intl.DateTimeFormat(undefined, {hour: 'numeric', minute: '2-digit'}); +const DAY = new Intl.DateTimeFormat(undefined, {weekday: 'short', month: 'short', day: 'numeric'}); + +// Where the event can go: every calendar the person can write to, named with its account where +// there are several. +type CalendarOption = {value: string; label: string; connectionId: string; calendarId: string}; + +function calendarValue(connectionId: string, calendarId: string): string { + return `${connectionId} ${calendarId}`; +} + +function calendarOptions(agenda: Agenda | null): CalendarOption[] { + const writable = agenda?.calendars.filter((calendar) => calendar.writable) ?? []; + const accounts = new Set(writable.map((calendar) => calendar.connectionId)); + return writable.map((calendar) => { + const account = agenda?.sources.find((source) => source.connectionId === calendar.connectionId); + return { + value: calendarValue(calendar.connectionId, calendar.id), + // A primary calendar is named after its account already. + label: + accounts.size > 1 && account && calendar.name !== account.account + ? `${calendar.name} · ${account.account}` + : calendar.name, + connectionId: calendar.connectionId, + calendarId: calendar.id, + }; + }); +} + +// New event: the title, when, who and where, the day it falls on with what is already in every +// calendar, so a clash shows before the invite goes out, and the calendar it goes in. +export function EventWidget({intent, typed, now}: {intent: Intent; typed: string; now: Date}) { + const names = namesIn(intent, 'attendees'); + const people = usePeople(names.length > 0 ? names : null); + const [chosen, setChosen] = useState>({}); + const guests = guestsOf(names, people.data, chosen); + const time = eventTime(typed, now); + const agenda = useAgenda(dayAround(time?.start ?? now)); + const [picked, setPicked] = useState(null); + const [location = ''] = fieldTexts(intent, 'location'); + const [title] = fieldTexts(intent, 'title'); + const firstNames = guests.map((guest) => (guest.person?.name ?? guest.name).split(' ')[0]); + const [edited, setTitle] = useSuggested( + title ?? (firstNames.length > 0 ? `${firstNames.join(', ')} and me` : 'New meeting'), + ); + const [status, setStatus] = useWriteStatus(typed); + + if (agenda.data?.sources.length === 0) { + return ( + + Connect a calendar to see your day and send invites from here. + + ); + } + + const options = calendarOptions(agenda.data); + const target = agenda.data?.target; + const calendar = + options.find((option) => option.value === picked) ?? + options.find( + (option) => target && option.value === calendarValue(target.connectionId, target.calendarId), + ) ?? + null; + const invited = guests.flatMap((guest) => (guest.address ? [guest.address] : [])); + + const create = () => { + if (!time || !calendar) { + return; + } + setStatus({kind: 'saving'}); + void createEvent({ + connectionId: calendar.connectionId, + calendarId: calendar.calendarId, + title: edited.trim(), + start: time.start.toISOString(), + end: time.end.toISOString(), + location, + guests: invited, + }).then((result) => { + if (result.isErr()) { + setStatus({kind: 'failed', message: writeErrorMessage(result.error)}); + return; + } + useAgenda.clear(); + setStatus({ + kind: 'done', + message: invited.length > 0 ? 'Invite sent' : 'Event created', + link: result.value.link, + }); + }); + }; + + return ( + } + actions={ + + } + > + setTitle(event.target.value)} /> +
+
+ + {time ? ( + <> + {DAY.format(time.start)} · {TIME.format(time.start)} – {TIME.format(time.end)} + {time.hourGuessed && · suggested} + + ) : ( + No time yet + )} + + {location || No location} + {options.length > 1 && ( + + + + )} +
    +
  • + + + Me · organizer + +
  • + {guests.map((guest) => ( + setChosen({...chosen, [guest.name]: key})} + /> + ))} +
+
+ {time ? : null} +
+
+ ); +} + +export function GuestRow({ + guest, + loading, + onChoose, +}: { + guest: Guest; + loading: boolean; + onChoose: (key: string) => void; +}) { + const shown = guest.person?.name ?? guest.name; + return ( +
  • + + {guest.matches.length > 1 && guest.person ? ( + ({ + value: personKey(match), + label: `${match.name} · ${match.emails[0] ?? 'no address'}`, + }))} + value={personKey(guest.person)} + onChange={onChoose} + /> + ) : ( + + {shown}{' '} + + · {guest.address ?? (loading ? 'looking…' : 'not in your contacts, not invited')} + + + )} +
  • + ); +} + +// The event's day with the day's events beside it, and what it clashes with. An event being moved +// (`moving`, its key) is left out, so it doesn't clash with itself, and shows where it goes as `title`. +export function DayAgenda({ + time, + agenda, + loading, + title = 'New event', + moving = null, +}: { + time: {start: Date; end: Date}; + agenda: Agenda | null; + loading: boolean; + title?: string; + moving?: string | null; +}) { + const items = timedItems(agenda?.events ?? [], time.start, null).filter( + (item) => item.key !== moving, + ); + const allDay = agenda?.events.filter((event) => event.allDay) ?? []; + const clashes = items.filter((item) => item.start < time.end && time.start < item.end); + const proposed: DayItem = { + key: 'new', + title, + start: time.start, + end: time.end, + highlighted: true, + link: null, + }; + + return ( +
    + {allDay.length > 0 && ( +

    + All day: {allDay.map((event) => event.title).join(', ')} +

    + )} + +

    + {!agenda && loading + ? 'Reading your calendar…' + : clashes.length > 0 + ? `Clashes with ${clashes.map((item) => item.title).join(' and ')}` + : 'Free then'} +

    +
    + ); +} diff --git a/packages/web/src/components/widgets/find-email-widget.tsx b/packages/web/src/components/widgets/find-email-widget.tsx new file mode 100644 index 00000000..558f5850 --- /dev/null +++ b/packages/web/src/components/widgets/find-email-widget.tsx @@ -0,0 +1,87 @@ +import {InboxIcon} from 'lucide-react'; +import type {Intent} from '@/client/intent-client'; +import {ConnectWidget} from '@/components/widgets/connect-widget'; +import {Missing, WidgetFrame} from '@/components/widgets/widget-frame'; +import {useEmails} from '@/lib/widget-lookups'; +import {emailQuery, senderName, shortDay, termsRead} from '@/lib/widget-values'; + +const SHORT_DAY = new Intl.DateTimeFormat(undefined, {month: 'short', day: 'numeric'}); + +// Find email: what it searches for, from the sender, the topic and the day it goes back to, and +// the newest emails every mailbox finds, each opening where it can. +export function FindEmailWidget({intent, typed, now}: {intent: Intent; typed: string; now: Date}) { + const query = emailQuery(intent, typed, now); + const emails = useEmails(query); + const sources = emails.data?.sources ?? []; + + if (emails.data && sources.length === 0) { + return ( + + Connect a mailbox to search your email from here. + + ); + } + + const found = emails.data?.emails ?? []; + const described = [ + ...(query.from ? [`from ${query.from}`] : []), + ...(query.words.length > 0 ? [`about ${termsRead(query.words)}`] : []), + ...(query.since ? [`since ${SHORT_DAY.format(new Date(`${query.since}T00:00`))}`] : []), + ]; + + return ( + +

    + Email + {described.length === 0 ? of every kind : described.join(' · ')} +

    +
      + {found.length === 0 && ( +
    • + {emails.loading ? 'Searching…' : 'No emails match.'} +
    • + )} + {found.map((email) => { + const account = + sources.length > 1 + ? sources.find((source) => source.connectionId === email.connectionId)?.account + : null; + const row = ( + <> + + {senderName(email.from)} + + + {email.subject || '(no subject)'} + + {account && ( + + {account} + + )} + + {shortDay(new Date(email.date))} + + + ); + return ( +
    • + {email.link ? ( + + {row} + + ) : ( +
      {row}
      + )} +
    • + ); + })} +
    +
    + ); +} diff --git a/packages/web/src/components/widgets/find-file-widget.tsx b/packages/web/src/components/widgets/find-file-widget.tsx new file mode 100644 index 00000000..2d209700 --- /dev/null +++ b/packages/web/src/components/widgets/find-file-widget.tsx @@ -0,0 +1,112 @@ +import { + FileIcon, + FileSearchIcon, + FileTextIcon, + FolderIcon, + PresentationIcon, + SheetIcon, + type LucideIcon, +} from 'lucide-react'; +import type {Intent} from '@/client/intent-client'; +import {ConnectWidget} from '@/components/widgets/connect-widget'; +import {Missing, WidgetFrame} from '@/components/widgets/widget-frame'; +import {useFiles, usePeople} from '@/lib/widget-lookups'; +import {bestMatch, fileSearch, isAddress, shortDay} from '@/lib/widget-values'; + +const KIND_ICONS: Partial> = { + Document: FileTextIcon, + Spreadsheet: SheetIcon, + Presentation: PresentationIcon, + Folder: FolderIcon, +}; + +// Find file: what it searches for, from the name, the kind said with it ("deck", "spreadsheet") +// and the owner, found among the contacts for their address, and the files every file store +// finds, each opening where it is. +export function FindFileWidget({intent, typed}: {intent: Intent; typed: string}) { + const search = fileSearch(intent, typed); + const ownerName = search.ownerName; + const people = usePeople(ownerName && !isAddress(ownerName) ? [ownerName] : null); + const owner = ownerName + ? isAddress(ownerName) + ? ownerName + : (bestMatch(people.data, ownerName)?.emails[0] ?? null) + : null; + // The owner is looked up first: a search without them would only be replaced. + const files = useFiles( + ownerName && !owner && people.loading ? null : {words: search.words, kind: search.kind, owner}, + ); + const sources = files.data?.sources ?? []; + + if (files.data && sources.length === 0) { + return ( + + Connect Google Drive to find your files from here. + + ); + } + + const found = files.data?.files ?? []; + const described = [ + ...(search.words.length > 0 ? [`named ${search.words.join(' ')}`] : []), + ...(search.kind ? [`${search.kind.toLowerCase()}s first`] : []), + ...(ownerName ? [`owned by ${owner ?? ownerName}`] : []), + ]; + + return ( + +

    + Files + {described.length === 0 ? changed most recently : described.join(' · ')} +

    + {found.length > 0 && found.every((file) => file.matched === 'content') && ( +

    + No file is named “{search.words.join(' ')}”; these have it inside. +

    + )} + {ownerName && !owner && !people.loading && ( +

    + No contact named “{ownerName}”, so files by anyone are shown. +

    + )} +
      + {found.length === 0 && ( +
    • + {files.loading || !files.data ? 'Searching…' : 'No files match.'} +
    • + )} + {found.map((file) => { + const Icon = KIND_ICONS[file.kind] ?? FileIcon; + const row = ( + <> + + {file.name} + + {file.owner} + + + {file.modified ? shortDay(new Date(file.modified)) : ''} + + + ); + return ( +
    • + {file.link ? ( + + {row} + + ) : ( +
      {row}
      + )} +
    • + ); + })} +
    +
    + ); +} diff --git a/packages/web/src/components/widgets/intent-widget.test.tsx b/packages/web/src/components/widgets/intent-widget.test.tsx new file mode 100644 index 00000000..398789b3 --- /dev/null +++ b/packages/web/src/components/widgets/intent-widget.test.tsx @@ -0,0 +1,434 @@ +/// + +import {afterEach, beforeEach, describe, expect, mock, test} from 'bun:test'; +import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react'; +import {MemoryRouter} from 'react-router'; +import {TooltipProvider} from '@proxy/ui/components/tooltip'; +import type {Intent} from '@/client/intent-client'; +import {IntentWidget} from '@/components/widgets/intent-widget'; +import {clearWidgetLookups, widgetsAnswer} from '@/test-widgets-api'; + +// Wednesday 14 October 2026, 14:30 local time. +const now = new Date(2026, 9, 14, 14, 30); + +const realFetch = globalThis.fetch; +const fetchMock = mock((input: string, init?: RequestInit) => + Promise.resolve(widgetsAnswer(input, init) ?? Response.json({}, {status: 404})), +); + +beforeEach(() => { + fetchMock.mockClear(); + onAsk.mockClear(); + globalThis.fetch = fetchMock as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = realFetch; + clearWidgetLookups(); + cleanup(); +}); + +function intentWith(widget: string, fields: Record): Intent { + return { + widget, + fields: Object.entries(fields).map(([id, values]) => ({id, name: id, values})), + ms: 0, + }; +} + +const onAsk = mock((_text: string) => {}); + +function show(intent: Intent, typed = '') { + return render( + + + + + , + ); +} + +function widgetNamed(name: string): HTMLElement { + return screen.getByRole('region', {name}); +} + +// What a widget sent to a route of the api, as JSON. +function sentTo(path: string): unknown { + const call = fetchMock.mock.calls.find( + ([input, init]) => input === path && init?.method === 'POST', + ); + return JSON.parse(String(call?.[1]?.body ?? 'null')); +} + +describe('IntentWidget', () => { + test('New event: guests from the contacts, the day with what clashes, and the invite sent', async () => { + show( + intentWith('new_event', {attendees: ['hugo'], location: ['Nopa']}), + 'book hugo tomorrow at 3pm at Nopa', + ); + + const widget = widgetNamed('New event'); + expect(within(widget).getByRole('textbox', {name: 'Title'})).toHaveProperty( + 'value', + 'hugo and me', + ); + expect(await within(widget).findByText(/hugo\.brandt@example\.com/)).toBeTruthy(); + expect(within(widget).getByRole('textbox', {name: 'Title'})).toHaveProperty( + 'value', + 'Hugo and me', + ); + expect(within(widget).getByText('Nopa')).toBeTruthy(); + // Thursday 3pm sits in the calendar's focus time. + expect(await within(widget).findByText('Clashes with Focus time')).toBeTruthy(); + // Two calendars can take it, so it says which. + expect(within(widget).getByRole('combobox', {name: 'Calendar'})).toBeTruthy(); + + fireEvent.click(within(widget).getByRole('button', {name: 'Send invite'})); + expect(await within(widget).findByText(/Invite sent/)).toBeTruthy(); + expect(within(widget).getByRole('link', {name: 'Open'}).getAttribute('href')).toBe( + 'https://calendar.google.com/event?eid=1', + ); + expect(sentTo('/api/widgets/events')).toEqual({ + connectionId: 'google-1', + calendarId: 'me@example.com', + title: 'Hugo and me', + start: new Date(2026, 9, 15, 15, 0).toISOString(), + end: new Date(2026, 9, 15, 15, 30).toISOString(), + location: 'Nopa', + guests: ['hugo.brandt@example.com'], + }); + expect(within(widget).getByRole('button', {name: 'Send invite'})).toHaveProperty( + 'disabled', + true, + ); + }); + + test('New event: a name several contacts have is a choice, the closest first', async () => { + show(intentWith('new_event', {attendees: ['jacky']}), 'lunch with jacky tomorrow at 1'); + + const which = await screen.findByRole('combobox', {name: 'Which jacky'}); + expect(which.textContent).toContain('Jacky Doe'); + }); + + test('Calendar: the day asked about, with what is in it', async () => { + show(intentWith('show_calendar', {}), "what's on my calendar tomorrow"); + + const widget = widgetNamed('Calendar'); + expect(within(widget).getByText(/^Tomorrow · Thu, Oct 15$/)).toBeTruthy(); + expect(await within(widget).findByText('Focus time')).toBeTruthy(); + expect(within(widget).getByText('1 event')).toBeTruthy(); + expect(fetchMock.mock.calls.some(([input]) => input.startsWith('/api/widgets/events'))).toBe( + false, + ); + }); + + test('Calendar: when the person is meeting someone, their day with the meeting highlighted, the next a click away', async () => { + show(intentWith('show_calendar', {meetingWith: ['hugo']}), 'when am i meeting hugo'); + + const widget = widgetNamed('Calendar'); + const meeting = await within(widget).findByRole('link', {current: true}); + expect(meeting.textContent).toBe('Hugo / me'); + expect(meeting.getAttribute('href')).toBe('https://calendar.google.com/event?eid=hugo-0'); + expect(within(widget).getByText(/with Hugo Brandt/)).toBeTruthy(); + expect(within(widget).getByText(/^Tue, Oct 20$/)).toBeTruthy(); + // Searched by his name and the address his contact has, from now on. + const searched = fetchMock.mock.calls.find(([input]) => + input.startsWith('/api/widgets/events'), + ); + const url = new URL(searched?.[0] ?? '', 'http://localhost'); + expect(url.searchParams.getAll('search')).toEqual(['hugo', 'hugo.brandt@example.com']); + expect(url.searchParams.get('from')).toBe(new Date(2026, 9, 14, 14, 30).toISOString()); + + fireEvent.click( + within(within(widget).getByRole('list', {name: 'Also found'})).getByRole('button'), + ); + expect(await within(widget).findByText(/^Tue, Oct 27$/)).toBeTruthy(); + expect(within(widget).getByRole('link', {current: true}).getAttribute('href')).toBe( + 'https://calendar.google.com/event?eid=hugo-1', + ); + }); + + test('Calendar: says when nothing is coming up', async () => { + show( + intentWith('show_calendar', {eventName: ['dentist']}), + 'when is my next dentist appointment', + ); + + expect(await within(widgetNamed('Calendar')).findByText(/nothing coming up/)).toBeTruthy(); + }); + + test('Change event: finds the event, moves it in its own week, adds a guest and sends the update', async () => { + show( + intentWith('change_event', { + changeWith: ['hugo'], + changeTo: ['thursday at 4'], + invite: ['jacky'], + }), + 'move my 1:1 with hugo to thursday at 4 and invite jacky', + ); + + const widget = widgetNamed('Change event'); + expect(await within(widget).findByDisplayValue('Hugo / me')).toBeTruthy(); + // From Tuesday's 3 to 4 to Thursday's 4 to 5, beside Thursday's focus time. + expect(within(widget).getByText(/^Tue, Oct 20 · 3:00 PM – 4:00 PM$/)).toBeTruthy(); + expect(within(widget).getByText(/Thu, Oct 22 · 4:00 PM – 5:00 PM/)).toBeTruthy(); + expect(await within(widget).findByText('Free then')).toBeTruthy(); + // Two of them coming up, so it says which. + expect(within(widget).getByRole('combobox', {name: 'Which event'})).toBeTruthy(); + expect(within(widget).getByText('hugo.brandt@example.com')).toBeTruthy(); + expect(await within(widget).findByRole('combobox', {name: 'Which jacky'})).toBeTruthy(); + + fireEvent.click(within(widget).getByRole('button', {name: 'Send update'})); + expect(await within(widget).findByText(/Update sent/)).toBeTruthy(); + const call = fetchMock.mock.calls.find( + ([input, init]) => input === '/api/widgets/events' && init?.method === 'PUT', + ); + expect(JSON.parse(String(call?.[1]?.body))).toEqual({ + connectionId: 'google-1', + calendarId: 'me@example.com', + eventId: 'hugo-0', + start: new Date(2026, 9, 22, 16).toISOString(), + end: new Date(2026, 9, 22, 17).toISOString(), + guests: ['hugo.brandt@example.com', 'jacky.doe@example.com'], + }); + }); + + test('Change event: nothing to save until something changes', async () => { + show(intentWith('change_event', {changeWith: ['hugo']}), 'change my meeting with hugo'); + + const widget = widgetNamed('Change event'); + expect(await within(widget).findByDisplayValue('Hugo / me')).toBeTruthy(); + expect(within(widget).getByRole('button', {name: 'Send update'})).toHaveProperty( + 'disabled', + true, + ); + fireEvent.change(within(widget).getByRole('textbox', {name: 'Title'}), { + target: {value: 'Hugo 1:1'}, + }); + expect(within(widget).getByRole('button', {name: 'Send update'})).toHaveProperty( + 'disabled', + false, + ); + }); + + test('a widget with nothing connected to read asks to connect it, and comes back home', async () => { + fetchMock.mockImplementationOnce(() => + Promise.resolve( + Response.json({sources: [], calendars: [], events: [], target: null, failed: []}), + ), + ); + show(intentWith('new_event', {}), 'meeting tomorrow at 2'); + + const connect = await screen.findByRole('link', {name: /Connect Google/}); + expect(connect.getAttribute('href')).toBe('/api/connections/google/start?returnTo=home'); + expect( + screen.getByText('Connect a calendar to see your day and send invites from here.'), + ).toBeTruthy(); + }); + + test('Person: the contact the name means, or that there is none', async () => { + show(intentWith('find_person', {person: ['hugo']})); + expect(await within(widgetNamed('Person')).findByText('Hugo Brandt')).toBeTruthy(); + expect(within(widgetNamed('Person')).getByText('+49 30 5557 0123')).toBeTruthy(); + + // Each address leads on, by typing what to do with it anew. + fireEvent.click( + within(widgetNamed('Person')).getByRole('button', { + name: 'Find emails from hugo.brandt@example.com', + }), + ); + expect(onAsk).toHaveBeenLastCalledWith('find emails from hugo.brandt@example.com'); + fireEvent.click( + within(widgetNamed('Person')).getByRole('button', {name: 'Write to hugo.brandt@example.com'}), + ); + expect(onAsk).toHaveBeenLastCalledWith('write an email to hugo.brandt@example.com'); + cleanup(); + + show(intentWith('find_person', {person: ['zed']})); + expect(await within(widgetNamed('Person')).findByText('No contact named “zed”')).toBeTruthy(); + }); + + test('Person: someone only in email, by their name and address', async () => { + fetchMock.mockImplementationOnce(() => + Promise.resolve( + Response.json({ + sources: [ + { + connectionId: 'google-1', + integrationId: 'google', + account: 'me@example.com', + name: null, + }, + ], + failed: [], + people: [ + { + name: 'rita', + matches: [ + { + origin: 'email', + connectionId: 'google-1', + id: 'rita@example.com', + name: 'Rita Alvarez', + emails: ['rita@example.com'], + phones: [], + company: '', + jobTitle: '', + addresses: [], + link: null, + }, + ], + }, + ], + }), + ), + ); + show(intentWith('find_person', {person: ['rita']})); + const widget = widgetNamed('Person'); + expect(await within(widget).findByText('Rita Alvarez')).toBeTruthy(); + expect(within(widget).getByText('In your email, not in your contacts')).toBeTruthy(); + expect(within(widget).getByText('rita@example.com')).toBeTruthy(); + expect(within(widget).queryByRole('link', {name: 'Open contact'})).toBeNull(); + }); + + test('Write email: recipients with their addresses, sent from the mailbox last written from', async () => { + show( + intentWith('write_email', {to: ['hugo', 'zed'], subject: ['lunch next week']}), + 'email hugo', + ); + + const widget = widgetNamed('Write email'); + expect(await within(widget).findByText('hugo.brandt@example.com')).toBeTruthy(); + expect(within(widget).getByText('no address')).toBeTruthy(); + expect(within(widget).getByRole('textbox', {name: 'Subject'})).toHaveProperty( + 'value', + 'lunch next week', + ); + fireEvent.change(within(widget).getByRole('textbox', {name: 'Message'}), { + target: {value: 'Free on Tuesday?'}, + }); + + await waitFor(() => + expect(within(widget).getByRole('button', {name: 'Send'})).toHaveProperty('disabled', false), + ); + fireEvent.click(within(widget).getByRole('button', {name: 'Send'})); + expect(await within(widget).findByText('Sent')).toBeTruthy(); + expect(sentTo('/api/widgets/emails')).toEqual({ + connectionId: 'google-1', + to: ['hugo.brandt@example.com'], + subject: 'lunch next week', + body: 'Free on Tuesday?', + send: true, + }); + }); + + test('Find email and Find file: what they search for, and what every source finds', async () => { + show(intentWith('find_email', {from: ['sam'], about: ['invoice']})); + const emails = widgetNamed('Find email'); + expect(within(emails).getByText('from sam · about invoice')).toBeTruthy(); + expect(await within(emails).findByText('Sam Lee')).toBeTruthy(); + expect(within(emails).getByText('Invoice #1042 for September')).toBeTruthy(); + expect(fetchMock).toHaveBeenCalledWith( + '/api/widgets/emails?from=sam&words=invoice', + expect.anything(), + ); + cleanup(); + + show(intentWith('find_file', {owner: ['paul']}), 'the pricing spreadsheet paul shared'); + const files = widgetNamed('Find file'); + expect(await within(files).findByText('Pricing 2026')).toBeTruthy(); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + '/api/widgets/files?words=pricing&kind=Spreadsheet&owner=paul.mercer%40example.com', + expect.anything(), + ), + ); + }); + + test('Math: works out the calculation the model read, rather than trusting the model with it', () => { + show(intentWith('calculate', {expression: ['18% * 86.40']})); + const widget = widgetNamed('Math'); + expect(within(widget).getByText('18% * 86.40 =')).toBeTruthy(); + expect(within(widget).getByLabelText('Result').textContent).toBe((15.552).toLocaleString()); + cleanup(); + + show(intentWith('calculate', {expression: ['alert(1)']})); + expect(within(widgetNamed('Math')).getByText('Can’t work out “alert(1)”')).toBeTruthy(); + }); + + test('Convert units: into the unit asked for, else its counterpart, with others of its kind', () => { + show(intentWith('convert_units', {amount: ['5'], fromUnit: ['mi'], toUnit: ['km']})); + const widget = widgetNamed('Convert units'); + expect(within(widget).getByText('5 miles =')).toBeTruthy(); + expect(within(widget).getByLabelText('Result').textContent).toBe( + `${(8.04672).toLocaleString(undefined, {maximumSignificantDigits: 6})} kilometres`, + ); + expect(within(widget).getByLabelText('Also').textContent).toContain('ft'); + expect(within(widget).getByLabelText('Also').textContent).not.toContain('mm'); + cleanup(); + + show(intentWith('convert_units', {amount: ['72'], fromUnit: ['f']})); + expect(within(widgetNamed('Convert units')).getByLabelText('Result').textContent).toBe( + `${(22.2222).toLocaleString(undefined, {maximumSignificantDigits: 6})} °C`, + ); + cleanup(); + + // Kilograms in miles is no conversion: the counterpart is taken instead. + show(intentWith('convert_units', {amount: ['2'], fromUnit: ['kg'], toUnit: ['mi']})); + expect(within(widgetNamed('Convert units')).getByLabelText('Result').textContent).toContain( + 'pounds', + ); + }); + + test("Convert units: money at the day's rates, a dollar into euros when no currency is said", async () => { + show(intentWith('convert_units', {amount: ['56'], fromUnit: ['usd'], toUnit: ['eur']})); + const widget = widgetNamed('Convert units'); + const euros = new Intl.NumberFormat(undefined, {style: 'currency', currency: 'EUR'}).format( + 56 / 1.1195, + ); + expect((await within(widget).findByLabelText('Result')).textContent).toBe(euros); + expect(within(widget).getByLabelText('Also').textContent).toContain('£'); + expect(within(widget).getByText(/European Central Bank rates of/)).toBeTruthy(); + }); + + test('Find file: the kind said is a preference, and files with the words inside say so', async () => { + fetchMock.mockImplementationOnce(() => + Promise.resolve( + Response.json({ + sources: [ + { + connectionId: 'google-1', + integrationId: 'google', + account: 'me@example.com', + name: null, + }, + ], + failed: [], + files: [ + { + connectionId: 'google-1', + id: 'd1', + name: 'Kickoff notes', + kind: 'Document', + owner: 'Me', + modified: '2026-10-01T00:00:00.000Z', + link: null, + matched: 'content', + }, + ], + }), + ), + ); + show(intentWith('find_file', {name: ['leichtle doc']}), 'find the leichtle doc'); + const widget = widgetNamed('Find file'); + expect(within(widget).getByText('named leichtle · documents first')).toBeTruthy(); + expect( + await within(widget).findByText('No file is named “leichtle”; these have it inside.'), + ).toBeTruthy(); + }); + + test('shows nothing when no widget fits', () => { + const {container} = show(intentWith('none', {})); + expect(container.innerHTML).toBe(''); + }); +}); diff --git a/packages/web/src/components/widgets/intent-widget.tsx b/packages/web/src/components/widgets/intent-widget.tsx new file mode 100644 index 00000000..650f7b80 --- /dev/null +++ b/packages/web/src/components/widgets/intent-widget.tsx @@ -0,0 +1,54 @@ +import type {Intent} from '@/client/intent-client'; +import {CalendarWidget} from '@/components/widgets/calendar-widget'; +import {ConvertWidget} from '@/components/widgets/convert-widget'; +import {EditEventWidget} from '@/components/widgets/edit-event-widget'; +import {EventWidget} from '@/components/widgets/event-widget'; +import {FindEmailWidget} from '@/components/widgets/find-email-widget'; +import {FindFileWidget} from '@/components/widgets/find-file-widget'; +import {MathWidget} from '@/components/widgets/math-widget'; +import {PersonWidget} from '@/components/widgets/person-widget'; +import {WriteEmailWidget} from '@/components/widgets/write-email-widget'; + +type Props = { + intent: Intent; + // What was classified: times are read from it whole, and a file search falls back on its words. + typed: string; + // When it was classified, which the widgets' times are read against. + now: Date; + // Types something else into the input, for a widget that leads to another: Person's Find emails. + onAsk: (text: string) => void; +}; + +// The widget the api picked, filled with its fields; nothing for `none`. The ids are the +// api's (`packages/api/src/intent/widgets.ts`). +export function IntentWidget({intent, typed, now, onAsk}: Props) { + const id = intent.widget; + if (id === 'new_event') { + return ; + } + if (id === 'change_event') { + return ; + } + if (id === 'show_calendar') { + return ; + } + if (id === 'find_person') { + return ; + } + if (id === 'write_email') { + return ; + } + if (id === 'find_email') { + return ; + } + if (id === 'find_file') { + return ; + } + if (id === 'calculate') { + return ; + } + if (id === 'convert_units') { + return ; + } + return null; +} diff --git a/packages/web/src/components/widgets/math-widget.tsx b/packages/web/src/components/widgets/math-widget.tsx new file mode 100644 index 00000000..59430356 --- /dev/null +++ b/packages/web/src/components/widgets/math-widget.tsx @@ -0,0 +1,31 @@ +import {CalculatorIcon} from 'lucide-react'; +import type {Intent} from '@/client/intent-client'; +import {CopyButton} from '@/components/copy-button'; +import {Missing, WidgetFrame} from '@/components/widgets/widget-frame'; +import {calculate, formatNumber} from '@/lib/calculate'; +import {fieldTexts} from '@/lib/widget-values'; + +// Math: the calculation the model read from what was typed, worked out here, since a model gets +// arithmetic wrong, with the answer to copy. +export function MathWidget({intent}: {intent: Intent}) { + const [expression] = fieldTexts(intent, 'expression'); + const result = expression ? calculate(expression) : null; + + return ( + + {!expression && Type a calculation} + {expression && result === null && Can’t work out “{expression}”} + {expression && result !== null && ( +
    +
    +

    {expression} =

    +

    + {formatNumber(result)} +

    +
    + +
    + )} +
    + ); +} diff --git a/packages/web/src/components/widgets/person-widget.tsx b/packages/web/src/components/widgets/person-widget.tsx new file mode 100644 index 00000000..8539f5f1 --- /dev/null +++ b/packages/web/src/components/widgets/person-widget.tsx @@ -0,0 +1,150 @@ +import { + BuildingIcon, + InboxIcon, + MailIcon, + MapPinIcon, + PencilLineIcon, + PhoneIcon, + UserRoundIcon, + type LucideIcon, +} from 'lucide-react'; +import {useState, type ReactNode} from 'react'; +import {IconButton} from '@/components/icon-button'; +import type {Intent} from '@/client/intent-client'; +import {buttonVariants} from '@proxy/ui/components/button'; +import {ConnectWidget} from '@/components/widgets/connect-widget'; +import {Avatar, DetailRow, Missing, WidgetFrame} from '@/components/widgets/widget-frame'; +import {usePeople} from '@/lib/widget-lookups'; +import {fieldTexts, matchesFor, personKey} from '@/lib/widget-values'; + +// Person: who someone is and how to reach them, found by the name as written in every contact +// book, or among the people in the email of every mailbox, the closest first, the others a click +// away. Each address leads on to the emails from it, or to writing to it, by typing that in anew. +export function PersonWidget({intent, onAsk}: {intent: Intent; onAsk: (text: string) => void}) { + const [name] = fieldTexts(intent, 'person'); + const people = usePeople(name ? [name] : null); + const [picked, setPicked] = useState(null); + const matches = name ? matchesFor(people.data, name) : []; + const contact = matches.find((match) => personKey(match) === picked) ?? matches[0] ?? null; + const others = matches.filter((match) => match !== contact); + + if (people.data?.sources.length === 0) { + return ( + + Connect your contacts to look people up from here. + + ); + } + + return ( + + Open contact + + ) + } + > + {!contact && ( + + {!name + ? 'Type a name to look someone up' + : people.loading + ? `Looking for “${name}”…` + : `No contact named “${name}”`} + + )} + {contact && ( + <> +
    + +
    +

    {contact.name}

    +

    + {contact.origin === 'email' + ? 'In your email, not in your contacts' + : [contact.jobTitle, contact.company].filter(Boolean).join(' · ')} +

    +
    +
    +
    + {contact.emails.map((email, index) => ( + + {email} + onAsk(`find emails from ${email}`)} + > + + + onAsk(`write an email to ${email}`)} + > + + + + ))} + {contact.phones.map((phone, index) => ( + + {phone} + + ))} + {contact.addresses[0] && ( + {contact.addresses[0]} + )} + {contact.origin === 'contacts' && + contact.emails.length === 0 && + contact.phones.length === 0 && + !contact.company && ( + + Nothing more in the contact + + )} +
    + {others.length > 0 && ( +

    + Or + {others.map((other, index) => ( + + ))} +

    + )} + + )} +
    + ); +} + +// One of a list of addresses or numbers, one a line, the list's icon on its first line alone. +function ListRow({icon: Icon, children}: {icon: LucideIcon | null; children: ReactNode}) { + return ( +
    + {Icon ? ( + + ) : ( + + )} + {children} +
    + ); +} diff --git a/packages/web/src/components/widgets/unsupported-widget.tsx b/packages/web/src/components/widgets/unsupported-widget.tsx new file mode 100644 index 00000000..bcb16c1c --- /dev/null +++ b/packages/web/src/components/widgets/unsupported-widget.tsx @@ -0,0 +1,41 @@ +import {BotIcon} from 'lucide-react'; +import {Link} from 'react-router'; +import {buttonVariants} from '@proxy/ui/components/button'; +import {WidgetFrame} from '@/components/widgets/widget-frame'; +import type {AgentLogin} from '@/lib/types'; + +// "Claude", "Claude or Poke", and past two, "one of your agents". +function whomToAsk(agents: AgentLogin[]): string { + const [first, second] = agents; + if (agents.length > 2 || !first) { + return 'one of your agents'; + } + return second ? `${first.name} or ${second.name}` : first.name; +} + +// What is typed fits no widget: the input only does a few things, so it says which, and that the +// rest is for the person's agent, or, with none yet, offers to add one. +export function UnsupportedWidget({agents}: {agents: AgentLogin[]}) { + const active = agents.filter((agent) => agent.revokedAt === null); + return ( + + Add an agent + + ) + } + > +

    + Quick actions make events, find people, emails and files, write emails, do math and convert + units.{' '} + {active.length > 0 + ? `For anything else, ask ${whomToAsk(active)}, which can work with your connections.` + : 'For anything else, add an agent and ask it: it can work with your connections.'} +

    +
    + ); +} diff --git a/packages/web/src/components/widgets/widget-frame.tsx b/packages/web/src/components/widgets/widget-frame.tsx new file mode 100644 index 00000000..b6856d9d --- /dev/null +++ b/packages/web/src/components/widgets/widget-frame.tsx @@ -0,0 +1,71 @@ +import type {LucideIcon} from 'lucide-react'; +import type {ReactNode} from 'react'; +import type {Failure} from '@/client/widgets-client'; +import {initials} from '@/lib/widget-values'; + +type Props = { + icon: LucideIcon; + name: string; + children: ReactNode; + // Its buttons, the main one last. + actions?: ReactNode; + // What its last write did, or why it failed, beside its buttons. + status?: ReactNode; + // The connections it couldn't read, which it shows without. + failed?: Failure[]; +}; + +// One widget, as the home page's input card opens into it: what it is, what it found, and its +// buttons. The card draws the border. +export function WidgetFrame({icon: Icon, name, children, actions, status, failed = []}: Props) { + return ( +
    +
    + + {name} +
    + {children} + {failed.length > 0 && ( +
      + {failed.map((failure) => ( +
    • + Couldn’t read {failure.account}: {failure.message} +
    • + ))} +
    + )} + {(status || actions) && ( +
    +
    {status}
    + {actions &&
    {actions}
    } +
    + )} +
    + ); +} + +export function Avatar({name, large}: {name: string; large?: boolean}) { + return ( + + {initials(name)} + + ); +} + +// A labelled line of a widget: its icon, then what it is. +export function DetailRow({icon: Icon, children}: {icon: LucideIcon; children: ReactNode}) { + return ( +
    + +
    {children}
    +
    + ); +} + +// Nothing filled yet, in the place the value goes. +export function Missing({children}: {children: ReactNode}) { + return {children}; +} diff --git a/packages/web/src/components/widgets/widget-select.tsx b/packages/web/src/components/widgets/widget-select.tsx new file mode 100644 index 00000000..4c4af52a --- /dev/null +++ b/packages/web/src/components/widgets/widget-select.tsx @@ -0,0 +1,46 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@proxy/ui/components/select'; + +type Option = {value: string; label: string}; + +// Where a widget writes, or which of several people a name stands for: a choice inline in the +// widget, shown only when there is one to make. +export function WidgetSelect({ + label, + options, + value, + onChange, +}: { + label: string; + options: Option[]; + value: string | null; + onChange: (value: string) => void; +}) { + return ( + + ); +} diff --git a/packages/web/src/components/widgets/write-email-widget.tsx b/packages/web/src/components/widgets/write-email-widget.tsx new file mode 100644 index 00000000..2a47050b --- /dev/null +++ b/packages/web/src/components/widgets/write-email-widget.tsx @@ -0,0 +1,124 @@ +import {MailIcon} from 'lucide-react'; +import {useState} from 'react'; +import type {Intent} from '@/client/intent-client'; +import {writeEmail} from '@/client/widgets-client'; +import {Badge} from '@proxy/ui/components/badge'; +import {Button} from '@proxy/ui/components/button'; +import {Input} from '@proxy/ui/components/input'; +import {Textarea} from '@proxy/ui/components/textarea'; +import {ConnectWidget} from '@/components/widgets/connect-widget'; +import {Missing, WidgetFrame} from '@/components/widgets/widget-frame'; +import {WidgetSelect} from '@/components/widgets/widget-select'; +import {WriteResult} from '@/components/widgets/write-result'; +import {useSuggested} from '@/lib/use-suggested'; +import {useMailboxes, usePeople} from '@/lib/widget-lookups'; +import {aboutPhrase, bestMatch, fieldTexts, isAddress, namesIn} from '@/lib/widget-values'; +import {useWriteStatus, writeErrorMessage} from '@/lib/write-status'; + +// Write email: the recipients found among the contacts, the subject, and a body to write, sent +// or saved as a draft from the mailbox picked, the one last written from first. +export function WriteEmailWidget({intent, typed}: {intent: Intent; typed: string}) { + const names = namesIn(intent, 'to'); + const people = usePeople(names.length > 0 ? names : null); + const mailboxes = useMailboxes(true); + const [picked, setPicked] = useState(null); + const [subject, setSubject] = useSuggested( + fieldTexts(intent, 'subject')[0] ?? aboutPhrase(typed) ?? '', + ); + const [body, setBody] = useSuggested(fieldTexts(intent, 'body')[0] ?? ''); + const [status, setStatus] = useWriteStatus(typed); + const recipients = names.map((name) => { + const person = isAddress(name) ? null : bestMatch(people.data, name); + return {name, person, address: isAddress(name) ? name : (person?.emails[0] ?? null)}; + }); + + if (mailboxes.data?.sources.length === 0) { + return ( + + Connect a mailbox to write and send email from here. + + ); + } + + const sources = mailboxes.data?.sources ?? []; + const from = + sources.find((source) => source.connectionId === picked) ?? + sources.find((source) => source.connectionId === mailboxes.data?.target) ?? + null; + const to = recipients.flatMap((recipient) => (recipient.address ? [recipient.address] : [])); + const busy = status.kind === 'saving' || status.kind === 'done'; + + const write = (send: boolean) => { + if (!from || to.length === 0) { + return; + } + setStatus({kind: 'saving'}); + void writeEmail({connectionId: from.connectionId, to, subject, body, send}).then((result) => { + if (result.isErr()) { + setStatus({kind: 'failed', message: writeErrorMessage(result.error)}); + return; + } + setStatus({kind: 'done', message: send ? 'Sent' : 'Saved as a draft', link: null}); + }); + }; + + return ( + } + actions={ + <> + + + + } + > + {sources.length > 1 && ( +
    + From + ({value: source.connectionId, label: source.account}))} + value={from?.connectionId ?? null} + onChange={setPicked} + /> +
    + )} +
    + To + {recipients.length === 0 && No one yet} + {recipients.map((recipient) => ( + + {recipient.person?.name ?? recipient.name} + + {recipient.address ?? (people.loading ? 'looking…' : 'no address')} + + + ))} +
    + setSubject(event.target.value)} + /> +