feat: add organization context model

Introduce organizations as the top-level workspace model with address and bank-detail fields.

Store the active organization on Better Auth sessions and load organization context for dashboard routes.

Add the migration that creates organizations and backfills existing records to the default organization.
This commit is contained in:
2026-06-06 14:14:00 +01:00
parent bf5a1ad5b5
commit b5ba84b19f
10 changed files with 1872 additions and 2 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ declare global {
interface Locals {
user?: User;
session?: Session;
session?: Session & { activeOrganizationId?: string | null };
}
// interface Error {}
+9
View File
@@ -14,6 +14,15 @@ export const auth = betterAuth({
enabled: true,
disableSignUp: true
},
session: {
additionalFields: {
activeOrganizationId: {
type: 'string',
required: false,
input: false
}
}
},
plugins: [
admin(),
sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array
+2 -1
View File
@@ -37,7 +37,8 @@ export const session = sqliteTable(
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
impersonatedBy: text('impersonated_by')
impersonatedBy: text('impersonated_by'),
activeOrganizationId: text('active_organization_id')
},
(table) => [index('session_userId_idx').on(table.userId)]
);
+23
View File
@@ -0,0 +1,23 @@
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { timestamps } from './shared.schema';
export const organizations = sqliteTable('organizations', {
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text('name').notNull(),
workspace: text('workspace').notNull().default('Default Workspace'),
addressLine1: text('address_line_1'),
addressLine2: text('address_line_2'),
city: text('city'),
region: text('region'),
postcode: text('postcode'),
country: text('country').notNull().default('United Kingdom'),
bankName: text('bank_name'),
bankAccountName: text('bank_account_name'),
bankAccountNumber: text('bank_account_number'),
bankSortCode: text('bank_sort_code'),
bankIban: text('bank_iban'),
bankSwift: text('bank_swift'),
...timestamps
});
+1
View File
@@ -9,6 +9,7 @@ export const task = sqliteTable('task', {
});
export * from './clients.schema';
export * from './organizations.schema';
export * from './addresses.schema';
export * from './contacts.schema';
export * from './rooms.schema';
+94
View File
@@ -0,0 +1,94 @@
import { asc, eq, isNull } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { organizations, session } from '$lib/server/db/schema';
const DEFAULT_ORGANIZATION_ID = 'default-organization';
type SessionWithOrganization = {
id: string;
activeOrganizationId?: string | null;
};
export async function loadOrganizationContext(locals: App.Locals) {
const rows = await db
.select()
.from(organizations)
.where(isNull(organizations.archivedAt))
.orderBy(asc(organizations.name));
if (rows.length === 0) {
const now = new Date();
await db.insert(organizations).values({
id: DEFAULT_ORGANIZATION_ID,
name: 'Steel City Stadium',
workspace: 'Blueprint Workspace',
createdAt: now,
updatedAt: now
});
rows.push({
id: DEFAULT_ORGANIZATION_ID,
name: 'Steel City Stadium',
workspace: 'Blueprint Workspace',
addressLine1: null,
addressLine2: null,
city: null,
region: null,
postcode: null,
country: 'United Kingdom',
bankName: null,
bankAccountName: null,
bankAccountNumber: null,
bankSortCode: null,
bankIban: null,
bankSwift: null,
createdAt: now,
updatedAt: now,
archivedAt: null
});
}
const currentSession = locals.session as SessionWithOrganization | undefined;
const activeOrganization =
rows.find((organization) => organization.id === currentSession?.activeOrganizationId) ??
rows[0];
if (currentSession && currentSession.activeOrganizationId !== activeOrganization.id) {
await db
.update(session)
.set({ activeOrganizationId: activeOrganization.id, updatedAt: new Date() })
.where(eq(session.id, currentSession.id));
currentSession.activeOrganizationId = activeOrganization.id;
}
return {
organizations: rows,
activeOrganization,
activeOrganizationId: activeOrganization.id
};
}
export async function setActiveOrganization(locals: App.Locals, organizationId: string) {
const [organization] = await db
.select()
.from(organizations)
.where(eq(organizations.id, organizationId))
.limit(1);
const currentSession = locals.session as SessionWithOrganization | undefined;
if (!organization || organization.archivedAt || !currentSession) {
return null;
}
await db
.update(session)
.set({ activeOrganizationId: organization.id, updatedAt: new Date() })
.where(eq(session.id, currentSession.id));
currentSession.activeOrganizationId = organization.id;
return organization;
}
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from '@sveltejs/kit';
import { loadOrganizationContext } from '$lib/server/organizations';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(303, '/login');
}
return loadOrganizationContext(locals);
};