diff --git a/packages/owletto-backend/src/__tests__/integration/agents/install-routes.test.ts b/packages/owletto-backend/src/__tests__/integration/agents/install-routes.test.ts index 8caf8dbd1..fd306c452 100644 --- a/packages/owletto-backend/src/__tests__/integration/agents/install-routes.test.ts +++ b/packages/owletto-backend/src/__tests__/integration/agents/install-routes.test.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono'; import { beforeAll, describe, expect, it } from 'vitest'; import { installRoutes } from '../../../agents/install-routes'; +import { ensureMemberEntity } from '../../../utils/member-entity'; import type { Env } from '../../../index'; import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; import { @@ -69,6 +70,18 @@ describe('POST /api/install', () => { WHERE id = ${personalOrg.id} `; await addUserToOrganization(user.id, personalOrg.id, 'owner'); + + // Pre-create the user's $member entity, mirroring what + // provisionMemberAndCoreIdentities does during signup. Tests that exercise + // WhatsApp identity linking need this to exist. + await ensureMemberEntity({ + organizationId: personalOrg.id, + userId: user.id, + name: user.name, + email: user.email, + role: 'owner', + status: 'active', + }); }); it('installs the template into the caller personal org and returns redirect info', async () => { @@ -138,4 +151,61 @@ describe('POST /api/install', () => { // Don't leak the orphan user into subsequent tests. await sql`DELETE FROM "user" WHERE id = ${userWithoutOrg.id}`; }); + + it('writes WhatsApp identities when whatsapp_phone is supplied', async () => { + const sql = getTestDb(); + const app = buildApp(user.id); + const res = await app.request('/api/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + templateAgentId: templateAgent.agentId, + whatsapp_phone: '07123456789', + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + whatsapp?: { phone: string; waJid: string }; + whatsappError?: string; + }; + expect(body.whatsappError).toBeUndefined(); + expect(body.whatsapp).toEqual({ + phone: '+447123456789', + waJid: '447123456789@s.whatsapp.net', + }); + + const identities = await sql` + SELECT namespace, identifier + FROM entity_identities + WHERE organization_id = ${personalOrg.id} + AND namespace IN ('phone', 'wa_jid') + AND deleted_at IS NULL + ORDER BY namespace + `; + expect(identities.map((r: { namespace: string; identifier: string }) => r)).toEqual([ + { namespace: 'phone', identifier: '+447123456789' }, + { namespace: 'wa_jid', identifier: '447123456789@s.whatsapp.net' }, + ]); + }); + + it('reports whatsappError when the phone is unparseable but still installs', async () => { + const app = buildApp(user.id); + const res = await app.request('/api/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + templateAgentId: templateAgent.agentId, + whatsapp_phone: 'not a phone', + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + agentId: string; + whatsappError?: string; + whatsapp?: unknown; + }; + expect(body.agentId).toBeTruthy(); + expect(body.whatsapp).toBeUndefined(); + expect(body.whatsappError).toBe('invalid_phone'); + }); }); diff --git a/packages/owletto-backend/src/agents/install-routes.ts b/packages/owletto-backend/src/agents/install-routes.ts index bdf206000..f181358c4 100644 --- a/packages/owletto-backend/src/agents/install-routes.ts +++ b/packages/owletto-backend/src/agents/install-routes.ts @@ -1,14 +1,19 @@ /** * Public install endpoints backing the /install/:slug landing pages. * - * A signed-in user POSTs { templateAgentId } and we mirror that template into - * the user's personal org (looked up via the personal_org_for_user_id tag we - * set in the user.create.after hook). Returns the new agent id so the landing - * page can redirect to /$org/agents/$id. + * A signed-in user POSTs { templateAgentId, whatsapp_phone? } and we: + * 1. Look up their personal org (created by the user.create.after hook). + * 2. Mirror the template's schema into that org via installAgentFromTemplate. + * 3. Optionally write a WhatsApp identity (`wa_jid` + `phone`) on their + * $member entity so the gateway can later route inbound WhatsApp + * messages from that number back to this user's org. + * + * Returns the new agent id, the target org slug, and a redirectTo path. */ import { type Context, Hono } from 'hono'; import { requireAuth } from '../auth/middleware'; +import { linkWhatsAppToMember } from '../auth/subject-identities'; import { getDb } from '../db/client'; import type { Env } from '../index'; import { errorMessage } from '../utils/errors'; @@ -53,7 +58,7 @@ installRoutes.post('/install', requireAuth, async (c) => { return c.json({ error: rateLimit.errorMessage }, 429); } - let body: { templateAgentId?: string; name?: string }; + let body: { templateAgentId?: string; name?: string; whatsapp_phone?: string }; try { body = await c.req.json(); } catch { @@ -76,24 +81,46 @@ installRoutes.post('/install', requireAuth, async (c) => { ); } + let installResult: Awaited>; try { - const result = await installAgentFromTemplate({ + installResult = await installAgentFromTemplate({ templateAgentId: body.templateAgentId, targetOrganizationId: personalOrg.id, userId: user.id, name: body.name, }); - return c.json({ - agentId: result.agentId, - organizationId: result.organizationId, - organizationSlug: personalOrg.slug, - created: result.created, - mirrored: result.mirrored, - redirectTo: `/${personalOrg.slug}/agents/${result.agentId}`, - }); } catch (error) { return c.json({ error: errorMessage(error) }, 400); } + + // Optional: link the user's WhatsApp number to their $member so inbound + // WA messages can be routed back here. Failure is non-fatal — agent is + // already installed; user can re-link later. + let whatsapp: { phone: string; waJid: string } | undefined; + let whatsappError: 'invalid_phone' | 'no_member' | undefined; + if (body.whatsapp_phone && typeof body.whatsapp_phone === 'string') { + const result = await linkWhatsAppToMember({ + organizationId: personalOrg.id, + email: user.email, + rawPhone: body.whatsapp_phone, + }); + if ('error' in result) { + whatsappError = result.error; + } else { + whatsapp = result; + } + } + + return c.json({ + agentId: installResult.agentId, + organizationId: installResult.organizationId, + organizationSlug: personalOrg.slug, + created: installResult.created, + mirrored: installResult.mirrored, + redirectTo: `/${personalOrg.slug}/agents/${installResult.agentId}`, + ...(whatsapp ? { whatsapp } : {}), + ...(whatsappError ? { whatsappError } : {}), + }); }); export { installRoutes }; diff --git a/packages/owletto-backend/src/auth/__tests__/subject-identities.test.ts b/packages/owletto-backend/src/auth/__tests__/subject-identities.test.ts new file mode 100644 index 000000000..cc8e7327e --- /dev/null +++ b/packages/owletto-backend/src/auth/__tests__/subject-identities.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { normalizePhoneE164, phoneToWhatsAppJid } from '../subject-identities'; + +describe('normalizePhoneE164', () => { + it('keeps a clean E.164 number', () => { + expect(normalizePhoneE164('+447123456789')).toBe('+447123456789'); + }); + + it('strips spaces, dashes, parens, and dots', () => { + expect(normalizePhoneE164('+44 (0) 7123-456.789')).toBe('+447123456789'); + expect(normalizePhoneE164(' + 44 7123 456 789 ')).toBe('+447123456789'); + }); + + it('treats leading 00 as international prefix', () => { + expect(normalizePhoneE164('00447123456789')).toBe('+447123456789'); + }); + + it('assumes UK +44 for a national-format number starting with 0', () => { + expect(normalizePhoneE164('07123456789')).toBe('+447123456789'); + }); + + it('rejects strings that do not produce 7-15 digits', () => { + expect(normalizePhoneE164('abc')).toBeNull(); + expect(normalizePhoneE164('+12')).toBeNull(); + expect(normalizePhoneE164('+1234567890123456')).toBeNull(); + }); + + it('handles non-UK E.164 too', () => { + expect(normalizePhoneE164('+15551234567')).toBe('+15551234567'); + expect(normalizePhoneE164('+33612345678')).toBe('+33612345678'); + }); +}); + +describe('phoneToWhatsAppJid', () => { + it('drops the leading + and appends the WhatsApp suffix', () => { + expect(phoneToWhatsAppJid('+447123456789')).toBe('447123456789@s.whatsapp.net'); + expect(phoneToWhatsAppJid('+15551234567')).toBe('15551234567@s.whatsapp.net'); + }); +}); diff --git a/packages/owletto-backend/src/auth/personal-org-provisioning.ts b/packages/owletto-backend/src/auth/personal-org-provisioning.ts index 6707b90eb..561c298f5 100644 --- a/packages/owletto-backend/src/auth/personal-org-provisioning.ts +++ b/packages/owletto-backend/src/auth/personal-org-provisioning.ts @@ -9,6 +9,7 @@ import { getDb } from '../db/client'; import { generateSecureToken } from './oauth/utils'; +import { provisionMemberAndCoreIdentities } from './subject-identities'; interface UserLike { id: string; @@ -164,8 +165,32 @@ export async function ensurePersonalOrganization(user: UserLike): Promise; + +/** + * Insert (or no-op on conflict) entity_identities rows pointing at the given + * member entity. The unique index on (organization_id, namespace, identifier) + * WHERE deleted_at IS NULL guards against duplicates. + */ +async function writeIdentities( + sql: Sql, + organizationId: string, + memberEntityId: number, + source: string, + rows: IdentityRow[] +): Promise { + for (const row of rows) { + await sql` + INSERT INTO entity_identities ( + organization_id, entity_id, namespace, identifier, source_connector + ) VALUES ( + ${organizationId}, ${memberEntityId}, ${row.namespace}, ${row.identifier}, ${source} + ) + ON CONFLICT (organization_id, namespace, identifier) WHERE deleted_at IS NULL + DO NOTHING + `; + } +} + +async function findMemberEntityIdByEmail( + sql: Sql, + organizationId: string, + email: string +): Promise { + const { emailField } = await resolveMemberSchemaFields(organizationId); + const rows = await sql.unsafe( + `SELECT id FROM entities + WHERE entity_type = '$member' + AND organization_id = $1 + AND metadata->>$2 = $3 + AND deleted_at IS NULL + LIMIT 1`, + [organizationId, emailField, email] + ); + if (rows.length === 0) return null; + return Number(rows[0].id); +} + +/** + * Create a $member entity for the user in the given org and write the core + * personal identifiers (auth_user_id, email). Idempotent — safe to call again. + */ +export async function provisionMemberAndCoreIdentities( + organizationId: string, + subject: PersonalSubject +): Promise<{ memberEntityId: number }> { + await ensureMemberEntity({ + organizationId, + userId: subject.userId, + name: subject.name?.trim() || subject.email.split('@')[0], + email: subject.email, + image: subject.image ?? undefined, + role: 'owner', + status: 'active', + }); + + const sql = getDb(); + const memberEntityId = await findMemberEntityIdByEmail(sql, organizationId, subject.email); + if (memberEntityId === null) { + throw new Error( + `Failed to locate $member entity for user ${subject.userId} in org ${organizationId} after ensureMemberEntity` + ); + } + + await writeIdentities(sql, organizationId, memberEntityId, 'auth:signup', [ + { namespace: 'auth_user_id', identifier: subject.userId }, + { namespace: 'email', identifier: subject.email.toLowerCase() }, + ]); + + return { memberEntityId }; +} + +/** + * Normalize a user-supplied phone string to E.164 (`+447123456789` form). + * - Strips spaces, dashes, parentheses, dots. + * - Accepts leading `+`, `00` (international prefix), or a UK national `0`. + * - Returns null if the result doesn't look like a 7-15 digit E.164 number. + */ +export function normalizePhoneE164(raw: string): string | null { + const cleaned = raw.replace(/[\s\-().]/g, ''); + let digits: string; + if (cleaned.startsWith('+')) { + digits = cleaned.slice(1); + } else if (cleaned.startsWith('00')) { + digits = cleaned.slice(2); + } else if (cleaned.startsWith('0')) { + // UK national format — assume +44 for this product (UK Self Assessment). + digits = `44${cleaned.slice(1)}`; + } else { + digits = cleaned; + } + // Drop the UK trunk-prefix "0" that often appears as `+44 (0) 71234...` + // after we've stripped parens. UK numbers in E.164 are 12 digits (44 + 10). + if (digits.startsWith('440') && digits.length === 13) { + digits = `44${digits.slice(3)}`; + } + if (!/^\d{7,15}$/.test(digits)) return null; + return `+${digits}`; +} + +/** + * Convert an E.164 phone (e.g. `+447123456789`) to a WhatsApp JID + * (`447123456789@s.whatsapp.net`). Group chats (`@g.us`) are out of scope — + * we only link individual users. + */ +export function phoneToWhatsAppJid(e164: string): string { + return `${e164.slice(1)}@s.whatsapp.net`; +} + +/** + * Attach a WhatsApp identity to the user's $member entity in their personal + * org. Idempotent. Returns the canonical phone + jid that were written so the + * caller can echo them back for confirmation. + */ +export async function linkWhatsAppToMember(params: { + organizationId: string; + email: string; + rawPhone: string; +}): Promise<{ phone: string; waJid: string } | { error: 'invalid_phone' | 'no_member' }> { + const phone = normalizePhoneE164(params.rawPhone); + if (!phone) return { error: 'invalid_phone' }; + const waJid = phoneToWhatsAppJid(phone); + + const sql = getDb(); + const memberEntityId = await findMemberEntityIdByEmail(sql, params.organizationId, params.email); + if (memberEntityId === null) return { error: 'no_member' }; + + await writeIdentities(sql, params.organizationId, memberEntityId, 'install:whatsapp', [ + { namespace: 'phone', identifier: phone }, + { namespace: 'wa_jid', identifier: waJid }, + ]); + + return { phone, waJid }; +} diff --git a/packages/owletto-backend/src/utils/member-entity.ts b/packages/owletto-backend/src/utils/member-entity.ts index 99dbe636c..ef5ba2ddd 100644 --- a/packages/owletto-backend/src/utils/member-entity.ts +++ b/packages/owletto-backend/src/utils/member-entity.ts @@ -14,7 +14,7 @@ import { ensureMemberEntityType, resolveMemberSchemaFieldsFromSchema } from './m * Resolve annotated field names from the $member entity type's metadata_schema. * Uses the `x-email`/`x-image` annotations; falls back to 'email' for email. */ -async function resolveMemberSchemaFields(organizationId: string): Promise<{ +export async function resolveMemberSchemaFields(organizationId: string): Promise<{ emailField: string; imageField?: string; }> {