From 23035b6ab487cf940613801b3211627204f646b4 Mon Sep 17 00:00:00 2001 From: AlacrisDevs Date: Sat, 7 Feb 2026 02:01:08 +0200 Subject: [PATCH] feat: auto-provision Matrix Space per org + migration 021 + /api/matrix-space endpoint --- src/routes/[orgSlug]/chat/+page.svelte | 31 ++++ src/routes/api/matrix-space/+server.ts | 172 +++++++++++++++++++ supabase/migrations/021_org_matrix_space.sql | 2 + 3 files changed, 205 insertions(+) create mode 100644 src/routes/api/matrix-space/+server.ts create mode 100644 supabase/migrations/021_org_matrix_space.sql diff --git a/src/routes/[orgSlug]/chat/+page.svelte b/src/routes/[orgSlug]/chat/+page.svelte index 1e53c3f..41e1da2 100644 --- a/src/routes/[orgSlug]/chat/+page.svelte +++ b/src/routes/[orgSlug]/chat/+page.svelte @@ -164,6 +164,9 @@ accessToken: credentials.accessToken, deviceId: credentials.deviceId || null, }); + + // Check if org has a Matrix Space, auto-create if not + await ensureOrgSpace(credentials); } catch (e: unknown) { console.error("Failed to init Matrix client:", e); toasts.error("Failed to connect to chat. Please re-login."); @@ -173,6 +176,34 @@ } } + async function ensureOrgSpace(credentials: LoginCredentials) { + try { + const spaceRes = await fetch(`/api/matrix-space?org_id=${data.org.id}`); + const spaceResult = await spaceRes.json(); + + if (!spaceResult.spaceId) { + // No Space yet — create one using the user's credentials + const createRes = await fetch("/api/matrix-space", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + org_id: data.org.id, + action: "create", + homeserver_url: credentials.homeserverUrl, + access_token: credentials.accessToken, + org_name: data.org.name, + }), + }); + const createResult = await createRes.json(); + if (createResult.spaceId) { + toasts.success(`Organization space created`); + } + } + } catch (e) { + console.warn("Failed to ensure org space:", e); + } + } + async function handleMatrixLogin() { if (!matrixUsername.trim() || !matrixPassword.trim()) { toasts.error("Please enter username and password"); diff --git a/src/routes/api/matrix-space/+server.ts b/src/routes/api/matrix-space/+server.ts new file mode 100644 index 0000000..ff69451 --- /dev/null +++ b/src/routes/api/matrix-space/+server.ts @@ -0,0 +1,172 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +// Cast supabase to any — matrix_space_id column added in migration 021 +// TODO: Remove after running `supabase gen types` +const db = (supabase: any) => supabase; + +/** + * GET: Retrieve the Matrix Space ID for an org + */ +export const GET: RequestHandler = async ({ url, locals }) => { + const session = await locals.safeGetSession(); + if (!session.user) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + const orgId = url.searchParams.get('org_id'); + if (!orgId) { + return json({ error: 'org_id is required' }, { status: 400 }); + } + + const { data, error } = await db(locals.supabase) + .from('organizations') + .select('matrix_space_id') + .eq('id', orgId) + .single(); + + if (error) { + return json({ error: error.message }, { status: 500 }); + } + + return json({ spaceId: data?.matrix_space_id ?? null }); +}; + +/** + * POST: Create a Matrix Space for an org, or link an existing one. + * + * Body options: + * - { org_id, action: "create", homeserver_url, access_token, org_name } + * Creates a new Space on the homeserver and stores the ID. + * - { org_id, action: "link", space_id } + * Links an existing Matrix Space ID to the org. + */ +export const POST: RequestHandler = async ({ request, locals }) => { + const session = await locals.safeGetSession(); + if (!session.user) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { org_id, action } = body; + + if (!org_id || !action) { + return json({ error: 'org_id and action are required' }, { status: 400 }); + } + + if (action === 'create') { + const { homeserver_url, access_token, org_name } = body; + if (!homeserver_url || !access_token || !org_name) { + return json({ error: 'homeserver_url, access_token, and org_name are required for create' }, { status: 400 }); + } + + try { + // Create a Matrix Space via the Client-Server API + const createRes = await fetch(`${homeserver_url}/_matrix/client/v3/createRoom`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: org_name, + topic: `Organization space for ${org_name}`, + visibility: 'private', + creation_content: { + type: 'm.space', + }, + initial_state: [ + { + type: 'm.room.guest_access', + state_key: '', + content: { guest_access: 'can_join' }, + }, + ], + power_level_content_override: { + invite: 50, + kick: 50, + ban: 50, + events_default: 0, + state_default: 50, + }, + }), + }); + + if (!createRes.ok) { + const err = await createRes.json().catch(() => ({})); + return json({ error: err.error || 'Failed to create Matrix Space' }, { status: 500 }); + } + + const { room_id: spaceId } = await createRes.json(); + + // Also create default #general room inside the space + const generalRes = await fetch(`${homeserver_url}/_matrix/client/v3/createRoom`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: 'General', + topic: 'General discussion', + visibility: 'private', + preset: 'private_chat', + }), + }); + + if (generalRes.ok) { + const { room_id: generalRoomId } = await generalRes.json(); + + // Add #general as a child of the space + await fetch( + `${homeserver_url}/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state/m.space.child/${encodeURIComponent(generalRoomId)}`, + { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + via: [new URL(homeserver_url).hostname], + }), + } + ); + } + + // Store space ID in org record + const { error: updateError } = await db(locals.supabase) + .from('organizations') + .update({ matrix_space_id: spaceId }) + .eq('id', org_id); + + if (updateError) { + return json({ error: updateError.message }, { status: 500 }); + } + + return json({ spaceId, created: true }); + } catch (e: any) { + console.error('Failed to create Matrix Space:', e); + return json({ error: e.message || 'Failed to create Matrix Space' }, { status: 500 }); + } + } + + if (action === 'link') { + const { space_id } = body; + if (!space_id) { + return json({ error: 'space_id is required for link action' }, { status: 400 }); + } + + const { error: updateError } = await db(locals.supabase) + .from('organizations') + .update({ matrix_space_id: space_id }) + .eq('id', org_id); + + if (updateError) { + return json({ error: updateError.message }, { status: 500 }); + } + + return json({ spaceId: space_id, linked: true }); + } + + return json({ error: 'Invalid action. Use "create" or "link".' }, { status: 400 }); +}; diff --git a/supabase/migrations/021_org_matrix_space.sql b/supabase/migrations/021_org_matrix_space.sql new file mode 100644 index 0000000..2b4ad1d --- /dev/null +++ b/supabase/migrations/021_org_matrix_space.sql @@ -0,0 +1,2 @@ +-- Add Matrix Space ID to organizations for org <-> space mapping +ALTER TABLE organizations ADD COLUMN IF NOT EXISTS matrix_space_id TEXT;