Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
});
});
55 changes: 41 additions & 14 deletions packages/owletto-backend/src/agents/install-routes.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand All @@ -76,24 +81,46 @@ installRoutes.post('/install', requireAuth, async (c) => {
);
}

let installResult: Awaited<ReturnType<typeof installAgentFromTemplate>>;
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 };
Original file line number Diff line number Diff line change
@@ -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');
});
});
29 changes: 27 additions & 2 deletions packages/owletto-backend/src/auth/personal-org-provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import { getDb } from '../db/client';
import { generateSecureToken } from './oauth/utils';
import { provisionMemberAndCoreIdentities } from './subject-identities';

interface UserLike {
id: string;
Expand Down Expand Up @@ -164,8 +165,32 @@ export async function ensurePersonalOrganization(user: UserLike): Promise<Ensure
result = { organizationId: orgId, slug, created: true };
});

if (!result) {
const finalResult = result as EnsureResult | null;
if (!finalResult) {
throw new Error('Personal organization transaction did not produce a result');
}
return result;

// Provision the $member entity + core identifiers (auth_user_id, email)
// outside the transaction. ensureMemberEntity uses createEntity which
// manages its own transaction and seeds the $member entity type if absent.
// Failures here shouldn't roll back the org creation — the user has a
// valid org, identity rows can be backfilled later. Run this for both newly
// created and pre-existing personal orgs; the helper is idempotent.
if (user.email) {
try {
await provisionMemberAndCoreIdentities(finalResult.organizationId, {
userId: user.id,
email: user.email,
name: user.name,
});
} catch (error) {
console.error('[Auth] Failed to provision $member entity for personal org:', {
orgId: finalResult.organizationId,
userId: user.id,
error: String(error),
});
}
}

return finalResult;
}
Loading
Loading