From c7aba3258f350da44e07608aa951b910b18cfcbe Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:25:44 +0000 Subject: [PATCH 1/9] initial work --- .env.example | 4 + bun.lock | 3 + docker-compose.yml | 1 + packages/api/src/activity/route.ts | 9 +- packages/api/src/connections/connect-flow.ts | 11 +- .../src/connections/google/calendar-api.ts | 15 +- packages/api/src/intent/classify.test.ts | 129 +++++++ packages/api/src/intent/classify.ts | 136 +++++++ packages/api/src/intent/route.test.ts | 34 ++ packages/api/src/intent/route.ts | 17 + packages/api/src/intent/widgets.ts | 121 ++++++ .../api/src/records/email-connector.test.ts | 33 +- packages/api/src/records/email-connector.ts | 48 +++ packages/api/src/records/email-search.ts | 31 ++ .../api/src/records/google-emails.test.ts | 15 + packages/api/src/records/google-emails.ts | 16 + packages/api/src/records/google-files.ts | 2 +- packages/api/src/server/create-api-router.ts | 22 ++ packages/api/src/utils/env-schema.ts | 6 + packages/api/src/utils/env.ts | 1 + packages/api/src/widgets/after-write.test.ts | 30 ++ packages/api/src/widgets/agenda.test.ts | 74 ++++ packages/api/src/widgets/agenda.ts | 73 ++++ packages/api/src/widgets/each-source.ts | 56 +++ packages/api/src/widgets/emails.ts | 130 +++++++ packages/api/src/widgets/files-route.test.ts | 47 +++ packages/api/src/widgets/files.test.ts | 42 +++ packages/api/src/widgets/files.ts | 119 ++++++ .../api/src/widgets/google-calendars.test.ts | 57 +++ packages/api/src/widgets/google-calendars.ts | 62 ++++ packages/api/src/widgets/mail-searches.ts | 23 ++ packages/api/src/widgets/new-event.ts | 40 ++ packages/api/src/widgets/people.test.ts | 151 ++++++++ packages/api/src/widgets/people.ts | 227 ++++++++++++ packages/api/src/widgets/rates.test.ts | 14 + packages/api/src/widgets/rates.ts | 36 ++ packages/api/src/widgets/sources.test.ts | 71 ++++ packages/api/src/widgets/sources.ts | 77 ++++ packages/api/src/widgets/writes.test.ts | 93 +++++ packages/api/src/widgets/writes.ts | 23 ++ packages/db/package.json | 3 +- .../v020_add_write_targets/migration.sql | 21 ++ packages/db/prisma/schema.prisma | 46 ++- packages/db/src/audit.ts | 26 +- packages/db/src/write-target.ts | 37 ++ packages/integrations/src/catalog.test.ts | 20 + packages/utils/package.json | 3 +- packages/utils/src/api-error.ts | 5 +- packages/utils/src/units.test.ts | 46 +++ packages/utils/src/units.ts | 192 ++++++++++ packages/web/package.json | 1 + packages/web/src/client/intent-client.ts | 17 + packages/web/src/client/schemas.ts | 3 +- packages/web/src/client/widgets-client.ts | 219 +++++++++++ .../web/src/components/animated-height.tsx | 29 ++ packages/web/src/components/app-shell.tsx | 23 +- .../web/src/components/audit-entry-dialog.tsx | 13 +- packages/web/src/components/audit-list.tsx | 26 +- packages/web/src/components/brand-logo.tsx | 15 +- .../web/src/components/connected-logos.tsx | 104 ++++++ packages/web/src/components/nav.tsx | 10 +- .../src/components/widgets/connect-widget.tsx | 60 +++ .../src/components/widgets/convert-widget.tsx | 119 ++++++ .../src/components/widgets/event-widget.tsx | 344 ++++++++++++++++++ .../components/widgets/find-email-widget.tsx | 87 +++++ .../components/widgets/find-file-widget.tsx | 112 ++++++ .../components/widgets/intent-widget.test.tsx | 333 +++++++++++++++++ .../src/components/widgets/intent-widget.tsx | 46 +++ .../src/components/widgets/math-widget.tsx | 31 ++ .../src/components/widgets/person-widget.tsx | 150 ++++++++ .../src/components/widgets/widget-frame.tsx | 71 ++++ .../src/components/widgets/widget-select.tsx | 46 +++ .../components/widgets/write-email-widget.tsx | 124 +++++++ .../src/components/widgets/write-result.tsx | 27 ++ packages/web/src/lib/calculate.test.ts | 45 +++ packages/web/src/lib/calculate.ts | 199 ++++++++++ packages/web/src/lib/integrations.ts | 5 +- packages/web/src/lib/lookup.ts | 84 +++++ packages/web/src/lib/use-suggested.ts | 11 + packages/web/src/lib/widget-lookups.ts | 26 ++ packages/web/src/lib/widget-values.test.ts | 171 +++++++++ packages/web/src/lib/widget-values.ts | 229 ++++++++++++ packages/web/src/lib/write-status.ts | 29 ++ packages/web/src/loaders.ts | 10 + packages/web/src/pages/activity-page.tsx | 8 +- packages/web/src/pages/home-page.test.tsx | 278 ++++++++++++++ packages/web/src/pages/home-page.tsx | 232 ++++++++++++ packages/web/src/router.tsx | 4 +- packages/web/src/test-widgets-api.ts | 175 +++++++++ render.yaml | 4 + 90 files changed, 5964 insertions(+), 54 deletions(-) create mode 100644 packages/api/src/intent/classify.test.ts create mode 100644 packages/api/src/intent/classify.ts create mode 100644 packages/api/src/intent/route.test.ts create mode 100644 packages/api/src/intent/route.ts create mode 100644 packages/api/src/intent/widgets.ts create mode 100644 packages/api/src/records/email-search.ts create mode 100644 packages/api/src/widgets/after-write.test.ts create mode 100644 packages/api/src/widgets/agenda.test.ts create mode 100644 packages/api/src/widgets/agenda.ts create mode 100644 packages/api/src/widgets/each-source.ts create mode 100644 packages/api/src/widgets/emails.ts create mode 100644 packages/api/src/widgets/files-route.test.ts create mode 100644 packages/api/src/widgets/files.test.ts create mode 100644 packages/api/src/widgets/files.ts create mode 100644 packages/api/src/widgets/google-calendars.test.ts create mode 100644 packages/api/src/widgets/google-calendars.ts create mode 100644 packages/api/src/widgets/mail-searches.ts create mode 100644 packages/api/src/widgets/new-event.ts create mode 100644 packages/api/src/widgets/people.test.ts create mode 100644 packages/api/src/widgets/people.ts create mode 100644 packages/api/src/widgets/rates.test.ts create mode 100644 packages/api/src/widgets/rates.ts create mode 100644 packages/api/src/widgets/sources.test.ts create mode 100644 packages/api/src/widgets/sources.ts create mode 100644 packages/api/src/widgets/writes.test.ts create mode 100644 packages/api/src/widgets/writes.ts create mode 100644 packages/db/prisma/migrations/v020_add_write_targets/migration.sql create mode 100644 packages/db/src/write-target.ts create mode 100644 packages/utils/src/units.test.ts create mode 100644 packages/utils/src/units.ts create mode 100644 packages/web/src/client/intent-client.ts create mode 100644 packages/web/src/client/widgets-client.ts create mode 100644 packages/web/src/components/animated-height.tsx create mode 100644 packages/web/src/components/connected-logos.tsx create mode 100644 packages/web/src/components/widgets/connect-widget.tsx create mode 100644 packages/web/src/components/widgets/convert-widget.tsx create mode 100644 packages/web/src/components/widgets/event-widget.tsx create mode 100644 packages/web/src/components/widgets/find-email-widget.tsx create mode 100644 packages/web/src/components/widgets/find-file-widget.tsx create mode 100644 packages/web/src/components/widgets/intent-widget.test.tsx create mode 100644 packages/web/src/components/widgets/intent-widget.tsx create mode 100644 packages/web/src/components/widgets/math-widget.tsx create mode 100644 packages/web/src/components/widgets/person-widget.tsx create mode 100644 packages/web/src/components/widgets/widget-frame.tsx create mode 100644 packages/web/src/components/widgets/widget-select.tsx create mode 100644 packages/web/src/components/widgets/write-email-widget.tsx create mode 100644 packages/web/src/components/widgets/write-result.tsx create mode 100644 packages/web/src/lib/calculate.test.ts create mode 100644 packages/web/src/lib/calculate.ts create mode 100644 packages/web/src/lib/lookup.ts create mode 100644 packages/web/src/lib/use-suggested.ts create mode 100644 packages/web/src/lib/widget-lookups.ts create mode 100644 packages/web/src/lib/widget-values.test.ts create mode 100644 packages/web/src/lib/widget-values.ts create mode 100644 packages/web/src/lib/write-status.ts create mode 100644 packages/web/src/pages/home-page.test.tsx create mode 100644 packages/web/src/pages/home-page.tsx create mode 100644 packages/web/src/test-widgets-api.ts diff --git a/.env.example b/.env.example index 61561eb..37d2f0c 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 7630e4f..7189a6a 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 b0b4f7d..825e439 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.ts b/packages/api/src/activity/route.ts index 03cad85..0246e59 100644 --- a/packages/api/src/activity/route.ts +++ b/packages/api/src/activity/route.ts @@ -12,7 +12,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,13 +25,17 @@ export type AuditEntryResponse = { via: 'web' | 'mcp'; }; +// `?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()}); /** 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} = $(parseSchema(querySchema, request.query)); const orgId = $(await requireUserOrgId(request)); + const filter = {...(agentId === undefined ? {} : {agentId: agentId === PROXY ? null : agentId}), ...(connectionId === undefined ? {} : {connectionId})}; const rows = $(await listAuditEntries(orgId, filter, MAX_ENTRIES)); return {entries: rows.map(({createdAt, ...entry}) => ({...entry, at: createdAt.toISOString()}))}; }); diff --git a/packages/api/src/connections/connect-flow.ts b/packages/api/src/connections/connect-flow.ts index a678eee..9a4190a 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 74e8b78..30515f2 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,22 @@ export function getCalendar(accessToken: string, calendarId: string): 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/intent/classify.test.ts b/packages/api/src/intent/classify.test.ts new file mode 100644 index 0000000..f2e2663 --- /dev/null +++ b/packages/api/src/intent/classify.test.ts @@ -0,0 +1,129 @@ +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', 'find_person', 'write_email', 'find_email', 'find_file', 'calculate', 'convert_units', 'none']); + expect(schema.required).toEqual(['widget', 'attendees', 'location', 'title', '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('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 0000000..99e753e --- /dev/null +++ b/packages/api/src/intent/classify.ts @@ -0,0 +1,136 @@ +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 {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; + +/** 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, or `none` when none fits or too little was typed yet. */ + widget: string; + /** The widget's fields, each with what fills it; none for `none`. */ + 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('; ')}`), + `- ${NO_WIDGET}: nothing above fits, or the text says too little yet`, +].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), 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, + max_tokens: 500, + // 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)}`))); + } + + 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 0000000..91dd29c --- /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 0000000..a67d774 --- /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 0000000..b8c15c7 --- /dev/null +++ b/packages/api/src/intent/widgets.ts @@ -0,0 +1,121 @@ +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. +// 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: '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 none of the widgets fits, or the text says too little yet. */ +export const NO_WIDGET = 'none'; diff --git a/packages/api/src/records/email-connector.test.ts b/packages/api/src/records/email-connector.test.ts index 71b7575..852de69 100644 --- a/packages/api/src/records/email-connector.test.ts +++ b/packages/api/src/records/email-connector.test.ts @@ -8,7 +8,7 @@ import {findCollection, findIntegration, type Collection} from '@proxy/integrati // 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 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; @@ -266,6 +266,37 @@ 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']); + }); + + 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); + 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}, + {or: [{from: 'jacky'}, {to: 'jacky'}, {cc: 'jacky'}]}, + {or: [{text: 'flight'}, {text: 'itinerary'}], not: {not: {text: 'SFO'}}}, + ]); + }); +}); + 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 3b4d4a2..d0c5cc8 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 @@ -231,6 +232,53 @@ async function folderPage(session: Session, search: string | null, before: numbe return {messages: await client.fetchAll(uids.join(','), 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}] : []), + ]); +} + +/** + * The newest emails in the inbox a home page search finds, at most `limit`: in Gmail's syntax on + * Gmail, else in IMAP's own terms. + */ +export function findInboxEmails(target: RecordTarget, search: EmailSearch, limit: number): Promise> { + return withFolder(target, INBOX, async (session) => { + const {client} = session; + const criteria: SearchObject = isGmail(client) ? {gmraw: gmailQuery(search)} : imapCriteria(search); + const uids = ((await client.search(criteria, {uid: true})) || []).sort((a, b) => b - a).slice(0, limit); + if (uids.length === 0) { + return Ok([]); + } + const messages = await client.fetchAll(uids.join(','), {uid: true, envelope: true, internalDate: true, flags: true}, {uid: true}); + return Ok(messages.sort((a, b) => b.uid - a.uid).map((message) => toRecord(session, message))); + }); +} + // Where each folder goes on, in a page token: the folder, its UIDVALIDITY, and the UID listing // continues below, as `inbox-7-120,sent-7-33`. A folder left out has nothing more to list. type Cursor = {folder: Folder; uidValidity: string; before: number}; diff --git a/packages/api/src/records/email-search.ts b/packages/api/src/records/email-search.ts new file mode 100644 index 0000000..f633f01 --- /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 70b70b4..8c487aa 100644 --- a/packages/api/src/records/google-emails.test.ts +++ b/packages/api/src/records/google-emails.test.ts @@ -173,6 +173,21 @@ 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: '50', q: 'from:"Sam Lee" planning after:2026/09/01'}); + expect(found.map((record) => [record.id, record.values.subject])).toEqual([ + ['m1', 'Q3 planning'], + ['m2', 'Re: dinner'], + ]); + 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(); diff --git a/packages/api/src/records/google-emails.ts b/packages/api/src/records/google-emails.ts index ac9916e..f8ec211 100644 --- a/packages/api/src/records/google-emails.ts +++ b/packages/api/src/records/google-emails.ts @@ -35,6 +35,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 +216,21 @@ function listPage(target: RecordTarget, query: ListQuery): Promise> { + return callGoogle(target.connection, (token) => + Do(async ($) => { + const page = $(await listMessageIds(token, folderQuery(null, gmailQuery(search)), null)); + const messages = $(present(await getMessageSummaries(token, page.items.slice(0, limit)))); + return messages.flatMap((message) => (message ? [toRecord(target.connection.account, message.id, message)] : [])); + }), + ); +} + 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-files.ts b/packages/api/src/records/google-files.ts index 1e1e486..7c902ed 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 0000000..90d05a1 --- /dev/null +++ b/packages/api/src/widgets/agenda.test.ts @@ -0,0 +1,74 @@ +import {beforeEach, describe, expect, mock, test} from 'bun:test'; +import {Ok} from 'ts-results-es'; + +const findSources = mock(); +const googleCalendars = 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.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).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(); + }); +}); diff --git a/packages/api/src/widgets/agenda.ts b/packages/api/src/widgets/agenda.ts new file mode 100644 index 0000000..9f6ebad --- /dev/null +++ b/packages/api/src/widgets/agenda.ts @@ -0,0 +1,73 @@ +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'; + +/** + * A stretch of time in every calendar connected, for the New event widget: the day an event is + * for, with what is already in it, and the calendars a new event can go in, the one the person + * last made one in first. + */ + +/** 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. + */ +export type AgendaEvent = {connectionId: string; calendarId: string; id: string; title: string; start: string; end: string; allDay: boolean; link: string | null}; + +/** How an integration's calendars are read; a calendar integration joins the widget with one. */ +export type CalendarAdapter = (source: Source, range: {from: string; to: 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; +} + +/** `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 findSources(orgId, 'events', 'list')).flatMap((source) => { + const adapter = ADAPTERS[source.integration.id]; + return adapter ? [{...source, adapter}] : []; + }); + const {found, failed} = await eachSource(sources, (source) => source.adapter(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}; + }); +} diff --git a/packages/api/src/widgets/each-source.ts b/packages/api/src/widgets/each-source.ts new file mode 100644 index 0000000..37023c3 --- /dev/null +++ b/packages/api/src/widgets/each-source.ts @@ -0,0 +1,56 @@ +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; + +function timeout(): Promise> { + return new Promise((resolve) => setTimeout(() => resolve(Err(ApiErr.providerUnreachable(new Error('Timed out')))), TIMEOUT_MS)); +} + +/** 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. + */ +export async function eachSource(sources: S[], work: (source: S) => Promise>): Promise> { + const results = await Promise.all(sources.map((source) => Promise.race([work(source), timeout()]))); + 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/emails.ts b/packages/api/src/widgets/emails.ts new file mode 100644 index 0000000..8653936 --- /dev/null +++ b/packages/api/src/widgets/emails.ts @@ -0,0 +1,130 @@ +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; + +// Gmail opens an email by its message id, in the account it is in. +function gmailLink(account: string, messageId: string): string { + return `https://mail.google.com/mail/?authuser=${encodeURIComponent(account)}#all/${messageId}`; +} + +function foundEmail(source: Source, record: DataRecord): FoundEmail { + const {values} = record; + const {id: connectionId, account, integrationId} = source.connection; + return { + connectionId, + id: record.id, + from: values.from ?? '', + subject: values.subject ?? '', + date: values.date ?? record.updatedAt, + unread: (values.status ?? '').startsWith('Unread'), + link: integrationId === 'google' ? gmailLink(account, record.id) : 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) => source.find(source, search, LIMIT)); + const emails = found + .flatMap(({source, value}) => value.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 0000000..77a6a0d --- /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 0000000..c85ab01 --- /dev/null +++ b/packages/api/src/widgets/files.test.ts @@ -0,0 +1,42 @@ +import {describe, expect, test} from 'bun:test'; + +import {gmailQuery} from '../records/email-search'; +import {driveQuery, rankFiles} from './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 0000000..9cf8bd4 --- /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 0000000..f4f3e64 --- /dev/null +++ b/packages/api/src/widgets/google-calendars.test.ts @@ -0,0 +1,57 @@ +import {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})); + +describe('googleCalendars', () => { + 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', start: {dateTime: '2026-10-15T09:30:00+02:00'}, end: {dateTime: '2026-10-15T09:45:00+02:00'}, htmlLink: 'https://calendar.google.com/standup'}, + {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'}}], + 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({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], + ]); + expect(read.events.map((event) => [event.title, event.allDay])).toEqual([ + ['Standup', false], + ['Offsite', true], + ['Busy', false], + ]); + expect(read.events[0]?.link).toBe('https://calendar.google.com/standup'); + }); +}); diff --git a/packages/api/src/widgets/google-calendars.ts b/packages/api/src/widgets/google-calendars.ts new file mode 100644 index 0000000..bea6a31 --- /dev/null +++ b/packages/api/src/widgets/google-calendars.ts @@ -0,0 +1,62 @@ +import {Do} from '@proxy/utils'; + +import {listCalendars, listEvents, 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 agenda: the calendars Google Calendar shows, ticked in its list, and + * their events in the range, 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. One calendar Google won't list the events of is left out too, rather + * than the account's whole day. + */ + +// A person ticks a dozen calendars at most; past that, the rest wait on the first. +const MAX_CALENDARS = 12; + +function calendarOf(connectionId: string, calendar: GoogleCalendar): AgendaCalendar { + return { + connectionId, + id: calendar.id, + name: calendar.summaryOverride ?? calendar.summary ?? calendar.id, + primary: calendar.primary === true, + writable: calendar.accessRole === 'owner' || calendar.accessRole === 'writer', + 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)'); + return [{connectionId, calendarId: calendar.id, id: event.id, title, start, end, allDay: event.start?.date !== undefined, link: event.htmlLink ?? null}]; +} + +export const googleCalendars: CalendarAdapter = (source, range) => + callGoogle(source.connection, (token) => + Do(async ($) => { + const connectionId = source.connection.id; + const shown = $(await listCalendars(token)) + .filter((calendar) => calendar.primary === true || calendar.selected === true) + .slice(0, MAX_CALENDARS); + const answers = await Promise.all(shown.map((calendar) => listEvents(token, calendar.id, {from: range.from, to: range.to, search: null, page: null}))); + const events = shown.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 []; + }); + return {calendars: shown.map((calendar) => calendarOf(connectionId, calendar)), events}; + }), + ); diff --git a/packages/api/src/widgets/mail-searches.ts b/packages/api/src/widgets/mail-searches.ts new file mode 100644 index 0000000..a991123 --- /dev/null +++ b/packages/api/src/widgets/mail-searches.ts @@ -0,0 +1,23 @@ +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. */ +export type MailSearch = (source: Source, search: EmailSearch, limit: number) => 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 0000000..be64498 --- /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.values.link || null}; + }); +} diff --git a/packages/api/src/widgets/people.test.ts b/packages/api/src/widgets/people.test.ts new file mode 100644 index 0000000..8d11603 --- /dev/null +++ b/packages/api/src/widgets/people.test.ts @@ -0,0 +1,151 @@ +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', link: `https://contacts.google.com/person/${id}`}, + updatedAt: '', + }; +} + +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 lists it: its sender and recipients, names where they were written. +function email(from: string, to: string) { + return {id: from, values: {from, to, 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'], + ]); + }); +}); + +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, search: {involving: string}) => Ok(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'); + expect(mailSearch).toHaveBeenCalledWith(expect.anything(), {from: null, involving: 'hugo', words: [], since: null}, 10); + 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 0000000..368fd13 --- /dev/null +++ b/packages/api/src/widgets/people.ts @@ -0,0 +1,227 @@ +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: values.link || 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 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 its senders and recipients 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 addresses = addressparser(`${record.values.from ?? ''}, ${record.values.to ?? ''}`, {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); + const searched = await Promise.all( + wanted.map(async (name) => { + const [inBooks, inEmail] = await Promise.all([ + eachSource(books, (source) => searchContacts(source, name)), + eachSource(mailboxes, (source) => + source + .find(source, {from: null, involving: name, words: [], since: null}, EMAILS_READ) + .then((found) => found.map((records) => peopleInEmails(source.connection.id, source.connection.account, records, name))), + ), + ]); + return {people: [...inBooks.found, ...inEmail.found].flatMap(({value}) => value), failed: [...inBooks.failed, ...inEmail.failed]}; + }), + ); + const failed = new Map(searched.flatMap((one) => one.failed.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(searched[index]?.people ?? [], 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 0000000..41b522b --- /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 0000000..7131c1f --- /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 0000000..0a81f9b --- /dev/null +++ b/packages/api/src/widgets/sources.test.ts @@ -0,0 +1,71 @@ +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[] => []); + +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 0000000..01b8ffe --- /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 0000000..4a11235 --- /dev/null +++ b/packages/api/src/widgets/writes.test.ts @@ -0,0 +1,93 @@ +import {beforeEach, describe, expect, mock, test} from 'bun:test'; +import {Err, Ok} from 'ts-results-es'; + +import {ApiErr} from '@proxy/utils'; + +const requireWritable = mock(); +const afterWrite = mock(async () => {}); +const create = mock(); +const sendNew = mock(); + +mock.module('./sources', () => ({requireWritable, findSources: mock(), summarize: mock()})); +mock.module('./writes', () => ({afterWrite})); +mock.module('../utils/user-org', () => ({requireUserOrgId: async () => Ok('org-1')})); + +const source = {connection: {id: 'work'}, collection: {id: 'events'}, connector: {create, 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', link: 'https://calendar.google.com/e1'}, updatedAt: ''})); + 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'); + }); +}); diff --git a/packages/api/src/widgets/writes.ts b/packages/api/src/widgets/writes.ts new file mode 100644 index 0000000..808f73a --- /dev/null +++ b/packages/api/src/widgets/writes.ts @@ -0,0 +1,23 @@ +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. + * 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}): 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}), 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 6802c41..43589aa 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 0000000..bc62e42 --- /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 f44571c..8b74309 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.ts b/packages/db/src/audit.ts index 2033a66..f4d3328 100644 --- a/packages/db/src/audit.ts +++ b/packages/db/src/audit.ts @@ -7,7 +7,8 @@ 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,10 +40,29 @@ 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; 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}, take: number, ): Promise> { return wrapDb(() => diff --git a/packages/db/src/write-target.ts b/packages/db/src/write-target.ts new file mode 100644 index 0000000..081c9b1 --- /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 16a7328..909fc50 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 5456b6a..49df6ef 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -4,7 +4,8 @@ "version": "0.0.0", "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.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 18328d1..a56efda 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/units.test.ts b/packages/utils/src/units.test.ts new file mode 100644 index 0000000..4fbb2aa --- /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 0000000..2f1aebb --- /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 0a06d35..168e3a9 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/intent-client.ts b/packages/web/src/client/intent-client.ts new file mode 100644 index 0000000..0034465 --- /dev/null +++ b/packages/web/src/client/intent-client.ts @@ -0,0 +1,17 @@ +import {z} from 'zod'; +import {apiRequest} from '@/client/request'; + +const intentSchema = z.object({ + // The widget's id, or `none`. + 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 e24b22f..8cd2176 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(), diff --git a/packages/web/src/client/widgets-client.ts b/packages/web/src/client/widgets-client.ts new file mode 100644 index 0000000..fe41f64 --- /dev/null +++ b/packages/web/src/client/widgets-client.ts @@ -0,0 +1,219 @@ +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(), + 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, + ); +} + +export type NewEvent = { + connectionId: string; + calendarId: string; + title: string; + start: string; + end: string; + location: string; + guests: string[]; +}; + +/** Makes the event; the calendar's provider invites its guests. */ +export function createEvent(event: NewEvent) { + return apiRequest( + 'POST', + '/api/widgets/events', + z.object({title: z.string(), link: z.string().nullable()}), + event, + ); +} + +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/animated-height.tsx b/packages/web/src/components/animated-height.tsx new file mode 100644 index 0000000..fb01d06 --- /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 1579f4c..c4707a9 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 5e31c1e..fc4c590 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 c1ca29a..9b6915d 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 ecb3f91..f3ea36b 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 0000000..00e563f --- /dev/null +++ b/packages/web/src/components/connected-logos.tsx @@ -0,0 +1,104 @@ +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 {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; + +function plural(count: number, one: string): string { + return `${count} ${one}${count === 1 ? '' : 's'}`; +} + +// 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 ? plural(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 ? plural(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 70bc4d0..1267664 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/connect-widget.tsx b/packages/web/src/components/widgets/connect-widget.tsx new file mode 100644 index 0000000..f28714f --- /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 0000000..8a8150d --- /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/event-widget.tsx b/packages/web/src/components/widgets/event-widget.tsx new file mode 100644 index 0000000..cc4a313 --- /dev/null +++ b/packages/web/src/components/widgets/event-widget.tsx @@ -0,0 +1,344 @@ +import {CalendarIcon, ClockIcon, MapPinIcon} from 'lucide-react'; +import type {Intent} from '@/client/intent-client'; +import { + createEvent, + type Agenda, + type AgendaEvent, + type People, + type Person, +} 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 {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, + isAddress, + matchesFor, + namesIn, + personKey, + type EventTime, +} from '@/lib/widget-values'; +import {useSuggested} from '@/lib/use-suggested'; +import {useWriteStatus, writeErrorMessage} from '@/lib/write-status'; +import {useState} from 'react'; + +const HOUR_PX = 28; +const TIME = new Intl.DateTimeFormat(undefined, {hour: 'numeric', minute: '2-digit'}); +const DAY = new Intl.DateTimeFormat(undefined, {weekday: 'short', month: 'short', day: 'numeric'}); + +// The day a time falls on, from its midnight to the next, on the person's clock. +function dayAround(date: Date): {from: string; to: string} { + const from = new Date(date); + from.setHours(0, 0, 0, 0); + const to = new Date(from); + to.setDate(to.getDate() + 1); + return {from: from.toISOString(), to: to.toISOString()}; +} + +// A guest as the widget shows them: the contact a name stands for, picked among those it found, +// or an address typed as it is. +type Guest = {name: string; person: Person | null; address: string | null; matches: Person[]}; + +function guestsOf(names: string[], people: People | null, chosen: Record): Guest[] { + return names.map((name) => { + if (isAddress(name)) { + return {name, person: null, address: name, matches: []}; + } + const matches = matchesFor(people, name); + const person = matches.find((match) => personKey(match) === chosen[name]) ?? matches[0] ?? null; + return {name, person, address: person?.emails[0] ?? null, matches}; + }); +} + +// 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} +
+
+ ); +} + +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')} + + + )} +
  • + ); +} + +function hours(date: Date): number { + return date.getHours() + date.getMinutes() / 60; +} + +type Timed = {title: string; start: Date; end: Date}; + +function timedOn(events: AgendaEvent[], time: EventTime): Timed[] { + const day = new Date(time.start); + day.setHours(0, 0, 0, 0); + const next = new Date(day); + next.setDate(next.getDate() + 1); + return events.flatMap((event) => { + if (event.allDay) { + return []; + } + const start = new Date(Math.max(Date.parse(event.start), day.getTime())); + const end = new Date(Math.min(Date.parse(event.end), next.getTime() - 60_000)); + return end > start ? [{title: event.title, start, end}] : []; + }); +} + +function overlaps(item: Timed, time: EventTime): boolean { + return item.start < time.end && time.start < item.end; +} + +// The event's day from 9 to 6, widened to take in it and the day's events, with them beside it. +function DayAgenda({ + time, + agenda, + loading, +}: { + time: EventTime; + agenda: Agenda | null; + loading: boolean; +}) { + const items = timedOn(agenda?.events ?? [], time); + const allDay = agenda?.events.filter((event) => event.allDay) ?? []; + const from = Math.min( + 9, + Math.floor(hours(time.start)), + ...items.map((item) => Math.floor(hours(item.start))), + ); + const to = Math.max( + 18, + Math.ceil(hours(time.end)), + ...items.map((item) => Math.ceil(hours(item.end))), + ); + const clashes = items.filter((item) => overlaps(item, time)); + 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 ( +
    + {allDay.length > 0 && ( +

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

    + )} +
    + {Array.from({length: to - from}, (_, index) => ( +
    + {TIME.format(new Date(2000, 0, 1, from + index))} +
    + ))} + {items.map((item, index) => ( +
    + {item.title} +
    + ))} +
    0 ? 'left-1/2' : 'left-16'}`} + style={{top: top(time.start), height: height(time.start, time.end)}} + > + New event +
    +
    +

    + {!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 0000000..558f585 --- /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 0000000..2d20970 --- /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 0000000..43aabb0 --- /dev/null +++ b/packages/web/src/components/widgets/intent-widget.test.tsx @@ -0,0 +1,333 @@ +/// + +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('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 0000000..36016d7 --- /dev/null +++ b/packages/web/src/components/widgets/intent-widget.tsx @@ -0,0 +1,46 @@ +import type {Intent} from '@/client/intent-client'; +import {ConvertWidget} from '@/components/widgets/convert-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 === '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 0000000..5943035 --- /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 0000000..8539f5f --- /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/widget-frame.tsx b/packages/web/src/components/widgets/widget-frame.tsx new file mode 100644 index 0000000..b6856d9 --- /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 0000000..4c4af52 --- /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 0000000..2a47050 --- /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)} + /> +