feat: auto-provision Matrix Space per org + migration 021 + /api/matrix-space endpoint
This commit is contained in:
@@ -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");
|
||||
|
||||
172
src/routes/api/matrix-space/+server.ts
Normal file
172
src/routes/api/matrix-space/+server.ts
Normal 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 });
|
||||
};
|
||||
2
supabase/migrations/021_org_matrix_space.sql
Normal file
2
supabase/migrations/021_org_matrix_space.sql
Normal 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;
|
||||
Reference in New Issue
Block a user