feat: auto-provision Matrix Space per org + migration 021 + /api/matrix-space endpoint

This commit is contained in:
AlacrisDevs
2026-02-07 02:01:08 +02:00
parent 3f267e3b13
commit 23035b6ab4
3 changed files with 205 additions and 0 deletions

View File

@@ -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");

View File

@@ -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 });
};

View File

@@ -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;