feat: add SaaS multi-tenant user management
CI / Detect Docs Changes (push) Waiting to run
CI / Test Core (push) Waiting to run
CI / Build Web (push) Waiting to run
CI / Build Server (push) Waiting to run
CI / Build Desktop (push) Waiting to run
CI / Typecheck (push) Waiting to run
CI / Repo Drift Check (push) Waiting to run
CI / Schema Check (SQLite) (push) Waiting to run
CI / Schema Check (MySQL) (push) Waiting to run
CI / Schema Check (Postgres) (push) Waiting to run
CI / Build Docs (push) Blocked by required conditions
CI / Audit Production Dependencies (push) Waiting to run
CI / Publish Docker Image (amd64) (push) Waiting to run
CI / Publish Docker Image (arm64) (push) Waiting to run
CI / Publish Docker Image (armv7) (push) Waiting to run
CI / Publish Docker Manifest (push) Blocked by required conditions
CodeQL / Analyze (JavaScript/TypeScript) (javascript-typescript) (push) Waiting to run

- Add organizations, organization_members, invitations, data_plans tables
- Add organizationService (CRUD, member management, plan limit check)
- Add invitationService (create, accept, cancel invitations)
- Add planService (plan limits, seed defaults)
- Add /api/organizations/* routes (org CRUD, members, invitations)
- Add /api/invitations/* routes (public invite lookup, accept)
- Seed default plans (free/pro/enterprise) on startup

Signed-off-by: Boos4721 <boos4721@icloud.com>
This commit is contained in:
2026-05-15 21:25:53 +08:00
parent 07013e2a39
commit 7886a0ab8b
8 changed files with 939 additions and 1 deletions
+53
View File
@@ -550,6 +550,8 @@ export const users = sqliteTable('users', {
passwordHash: text('password_hash').notNull(),
role: text('role').notNull().default('user'),
status: text('status').notNull().default('active'),
emailVerified: integer('email_verified').default(0),
avatarUrl: text('avatar_url'),
createdAt: text('created_at').default(sql`(datetime('now'))`),
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
}, (table) => ({
@@ -572,3 +574,54 @@ export const events = sqliteTable('events', {
typeCreatedIdx: index('events_type_created_at_idx').on(table.type, table.createdAt),
createdAtIdx: index('events_created_at_idx').on(table.createdAt),
}));
// SaaS Multi-Tenant Tables
export const organizations = sqliteTable('organizations', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
plan: text('plan').notNull().default('free'),
status: text('status').notNull().default('active'),
createdAt: text('created_at').default(sql`(datetime('now'))`),
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
}, (table) => ({
slugIdx: uniqueIndex('org_slug_idx').on(table.slug),
planIdx: index('org_plan_idx').on(table.plan),
}));
export const organizationMembers = sqliteTable('organization_members', {
id: integer('id').primaryKey({ autoIncrement: true }),
orgId: integer('org_id').notNull().references(() => organizations.id, { onDelete: 'cascade' }),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
role: text('role').notNull().default('member'),
status: text('status').notNull().default('active'),
joinedAt: text('joined_at').default(sql`(datetime('now'))`),
}, (table) => ({
orgMemberUnique: uniqueIndex('org_member_unique').on(table.orgId, table.userId),
orgIdIdx: index('org_members_org_id_idx').on(table.orgId),
userIdIdx: index('org_members_user_id_idx').on(table.userId),
}));
export const invitations = sqliteTable('invitations', {
id: integer('id').primaryKey({ autoIncrement: true }),
orgId: integer('org_id').notNull().references(() => organizations.id, { onDelete: 'cascade' }),
email: text('email').notNull(),
token: text('token').notNull().unique(),
role: text('role').notNull().default('member'),
invitedBy: integer('invited_by').references(() => users.id),
status: text('status').notNull().default('pending'),
expiresAt: text('expires_at').notNull(),
createdAt: text('created_at').default(sql`(datetime('now'))`),
}, (table) => ({
tokenUnique: uniqueIndex('invitations_token_unique').on(table.token),
statusIdx: index('invitations_status_idx').on(table.status),
}));
export const dataPlans = sqliteTable('data_plans', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull().unique(),
maxMembers: integer('max_members').notNull(),
maxApiKeys: integer('max_api_keys').notNull(),
maxRequestsPerDay: integer('max_requests_per_day').notNull(),
createdAt: text('created_at').default(sql`(datetime('now'))`),
});
+3 -1
View File
@@ -10,7 +10,9 @@ export function isPublicApiRoute(url: string): boolean {
|| url === '/api/users/register'
|| url === '/api/users/login'
|| url.startsWith('/api/users/')
|| url.startsWith('/api/user-api-keys/');
|| url.startsWith('/api/user-api-keys/')
|| url.startsWith('/api/organizations')
|| url.startsWith('/api/invitations/');
}
export async function registerDesktopRoutes(app: FastifyInstance) {
+10
View File
@@ -22,6 +22,8 @@ import { monitorRoutes } from './routes/api/monitor.js';
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
import { usersRoutes } from './routes/api/users.js';
import { userApiKeysRoutes } from './routes/api/userApiKeys.js';
import { organizationsRoutes } from './routes/api/organizations.js';
import { invitationRoutes } from './routes/api/invitations.js';
import { oauthRoutes } from './routes/api/oauth.js';
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
import { updateCenterRoutes } from './routes/api/updateCenter.js';
@@ -237,6 +239,8 @@ await app.register(downstreamApiKeysRoutes);
await app.register(adminUsersRoutes);
await app.register(usersRoutes);
await app.register(userApiKeysRoutes);
await app.register(invitationRoutes);
await app.register(organizationsRoutes);
await app.register(comfyuiRoutes);
await app.register(videoAgentRoutes);
await app.register(comfyuiAgentRoutes);
@@ -304,6 +308,12 @@ app.addHook('onClose', async () => {
// Start server
try {
// Seed default plans for SaaS
try {
const { seedDefaultPlans } = await import('./services/planService.js');
await seedDefaultPlans();
} catch { /* plan table may not exist yet */ }
await app.listen({ port: config.port, host: config.listenHost });
const summaryLines = buildStartupSummaryLines({
port: config.port,
+39
View File
@@ -0,0 +1,39 @@
import { FastifyInstance } from 'fastify';
import { userAuthMiddleware, getUserAuthContext } from '../../middleware/auth.js';
import { getInvitationByToken, acceptInvitation } from '../../services/invitationService.js';
import { getOrganizationById } from '../../services/organizationService.js';
export async function invitationRoutes(app: FastifyInstance) {
// GET /api/invitations/:token - Get invitation details (public)
app.get('/api/invitations/:token', async (request, reply) => {
const token = String((request.params as Record<string, unknown>).token);
if (!token) {
return reply.code(400).send({ error: 'Invalid invitation token' });
}
const invitation = await getInvitationByToken(token);
if (!invitation) {
return reply.code(404).send({ error: 'Invitation not found or expired' });
}
reply.send({ invitation });
});
// POST /api/invitations/:token/accept - Accept invitation (JWT auth required)
app.post('/api/invitations/:token/accept', async (request, reply) => {
await userAuthMiddleware(request, reply);
if (reply.sent) return;
const ctx = getUserAuthContext(request);
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
const token = String((request.params as Record<string, unknown>).token);
if (!token) {
return reply.code(400).send({ error: 'Invalid invitation token' });
}
try {
const orgId = await acceptInvitation(token, ctx.userId);
const organization = await getOrganizationById(orgId);
reply.send({ organization });
} catch (error) {
reply.code(400).send({ error: (error as Error).message || 'Failed to accept invitation' });
}
});
}
+262
View File
@@ -0,0 +1,262 @@
import { FastifyInstance } from 'fastify';
import { and, eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { userAuthMiddleware, getUserAuthContext } from '../../middleware/auth.js';
import {
createOrganization,
getOrganizationById,
listUserOrganizations,
listOrganizationMembers,
isOrgMember,
updateOrganization,
updateMemberRole,
removeMember,
} from '../../services/organizationService.js';
import {
createInvitation,
cancelInvitation,
listOrgInvitations,
} from '../../services/invitationService.js';
import { listAllPlans } from '../../services/planService.js';
async function requireOrgAdmin(orgId: number, userId: number) {
const member = await db.select({ role: schema.organizationMembers.role })
.from(schema.organizationMembers)
.where(
and(
eq(schema.organizationMembers.orgId, orgId),
eq(schema.organizationMembers.userId, userId),
),
)
.get();
if (!member || !['owner', 'admin'].includes(member.role)) {
throw new Error('Admin access required');
}
}
export async function organizationsRoutes(app: FastifyInstance) {
// GET /api/organizations - List user's organizations
app.get('/api/organizations', async (request, reply) => {
await userAuthMiddleware(request, reply);
if (reply.sent) return;
const ctx = getUserAuthContext(request);
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
const organizations = await listUserOrganizations(ctx.userId);
reply.send({ organizations });
});
// POST /api/organizations - Create new organization
app.post('/api/organizations', async (request, reply) => {
await userAuthMiddleware(request, reply);
if (reply.sent) return;
const ctx = getUserAuthContext(request);
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
const body = request.body as Record<string, unknown>;
if (!body.name || !body.slug) {
return reply.code(400).send({ error: 'Name and slug are required' });
}
const organization = await createOrganization(
String(body.name),
String(body.slug),
ctx.userId,
);
reply.code(201).send({ organization });
});
// GET /api/organizations/plans - List available plans with limits
app.get('/api/organizations/plans', async (request, reply) => {
await userAuthMiddleware(request, reply);
if (reply.sent) return;
const ctx = getUserAuthContext(request);
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
const plans = await listAllPlans();
reply.send({ plans });
});
// GET /api/organizations/:orgId - Get org details (must be member)
app.get('/api/organizations/:orgId', async (request, reply) => {
await userAuthMiddleware(request, reply);
if (reply.sent) return;
const ctx = getUserAuthContext(request);
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
const orgId = Number((request.params as Record<string, unknown>).orgId);
if (!Number.isFinite(orgId)) {
return reply.code(400).send({ error: 'Invalid organization ID' });
}
const member = await isOrgMember(orgId, ctx.userId);
if (!member) {
return reply.code(403).send({ error: 'You are not a member of this organization' });
}
const organization = await getOrganizationById(orgId);
if (!organization) {
return reply.code(404).send({ error: 'Organization not found' });
}
reply.send({ organization });
});
// PATCH /api/organizations/:orgId - Update org (owner/admin only)
app.patch('/api/organizations/:orgId', async (request, reply) => {
await userAuthMiddleware(request, reply);
if (reply.sent) return;
const ctx = getUserAuthContext(request);
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
const orgId = Number((request.params as Record<string, unknown>).orgId);
if (!Number.isFinite(orgId)) {
return reply.code(400).send({ error: 'Invalid organization ID' });
}
try {
await requireOrgAdmin(orgId, ctx.userId);
} catch {
return reply.code(403).send({ error: 'Admin access required' });
}
const body = request.body as Record<string, unknown>;
const updates: { name?: string; slug?: string } = {};
if (body.name !== undefined) updates.name = String(body.name);
if (body.slug !== undefined) updates.slug = String(body.slug);
if (Object.keys(updates).length === 0) {
return reply.code(400).send({ error: 'No fields to update' });
}
await updateOrganization(orgId, updates);
const organization = await getOrganizationById(orgId);
reply.send({ organization });
});
// GET /api/organizations/:orgId/members - List members
app.get('/api/organizations/:orgId/members', async (request, reply) => {
await userAuthMiddleware(request, reply);
if (reply.sent) return;
const ctx = getUserAuthContext(request);
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
const orgId = Number((request.params as Record<string, unknown>).orgId);
if (!Number.isFinite(orgId)) {
return reply.code(400).send({ error: 'Invalid organization ID' });
}
const member = await isOrgMember(orgId, ctx.userId);
if (!member) {
return reply.code(403).send({ error: 'You are not a member of this organization' });
}
const members = await listOrganizationMembers(orgId);
reply.send({ members });
});
// PATCH /api/organizations/:orgId/members/:userId - Change member role (owner/admin only)
app.patch('/api/organizations/:orgId/members/:userId', async (request, reply) => {
await userAuthMiddleware(request, reply);
if (reply.sent) return;
const ctx = getUserAuthContext(request);
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
const orgId = Number((request.params as Record<string, unknown>).orgId);
const targetUserId = Number((request.params as Record<string, unknown>).userId);
if (!Number.isFinite(orgId) || !Number.isFinite(targetUserId)) {
return reply.code(400).send({ error: 'Invalid organization ID or user ID' });
}
try {
await requireOrgAdmin(orgId, ctx.userId);
} catch {
return reply.code(403).send({ error: 'Admin access required' });
}
const body = request.body as Record<string, unknown>;
if (!body.role || !['owner', 'admin', 'member'].includes(String(body.role))) {
return reply.code(400).send({ error: 'Role must be one of: owner, admin, member' });
}
await updateMemberRole(orgId, targetUserId, String(body.role) as any);
reply.send({ success: true });
});
// DELETE /api/organizations/:orgId/members/:userId - Remove member (owner/admin only)
app.delete('/api/organizations/:orgId/members/:userId', async (request, reply) => {
await userAuthMiddleware(request, reply);
if (reply.sent) return;
const ctx = getUserAuthContext(request);
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
const orgId = Number((request.params as Record<string, unknown>).orgId);
const targetUserId = Number((request.params as Record<string, unknown>).userId);
if (!Number.isFinite(orgId) || !Number.isFinite(targetUserId)) {
return reply.code(400).send({ error: 'Invalid organization ID or user ID' });
}
try {
await requireOrgAdmin(orgId, ctx.userId);
} catch {
return reply.code(403).send({ error: 'Admin access required' });
}
await removeMember(orgId, targetUserId);
reply.code(204).send();
});
// GET /api/organizations/:orgId/invitations - List invitations (admin only)
app.get('/api/organizations/:orgId/invitations', async (request, reply) => {
await userAuthMiddleware(request, reply);
if (reply.sent) return;
const ctx = getUserAuthContext(request);
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
const orgId = Number((request.params as Record<string, unknown>).orgId);
if (!Number.isFinite(orgId)) {
return reply.code(400).send({ error: 'Invalid organization ID' });
}
try {
await requireOrgAdmin(orgId, ctx.userId);
} catch {
return reply.code(403).send({ error: 'Admin access required' });
}
const invitations = await listOrgInvitations(orgId);
reply.send({ invitations });
});
// POST /api/organizations/:orgId/invitations - Create invitation (admin only)
app.post('/api/organizations/:orgId/invitations', async (request, reply) => {
await userAuthMiddleware(request, reply);
if (reply.sent) return;
const ctx = getUserAuthContext(request);
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
const orgId = Number((request.params as Record<string, unknown>).orgId);
if (!Number.isFinite(orgId)) {
return reply.code(400).send({ error: 'Invalid organization ID' });
}
try {
await requireOrgAdmin(orgId, ctx.userId);
} catch {
return reply.code(403).send({ error: 'Admin access required' });
}
const body = request.body as Record<string, unknown>;
if (!body.email) {
return reply.code(400).send({ error: 'Email is required' });
}
const role = String(body.role || 'member');
if (!['admin', 'member'].includes(role)) {
return reply.code(400).send({ error: 'Role must be one of: admin, member' });
}
const invitation = await createInvitation(orgId, String(body.email), role as 'admin' | 'member', ctx.userId);
reply.code(201).send({ invitation });
});
// DELETE /api/organizations/:orgId/invitations/:invId - Cancel invitation (admin only)
app.delete('/api/organizations/:orgId/invitations/:invId', async (request, reply) => {
await userAuthMiddleware(request, reply);
if (reply.sent) return;
const ctx = getUserAuthContext(request);
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
const orgId = Number((request.params as Record<string, unknown>).orgId);
const invId = Number((request.params as Record<string, unknown>).invId);
if (!Number.isFinite(orgId) || !Number.isFinite(invId)) {
return reply.code(400).send({ error: 'Invalid organization ID or invitation ID' });
}
try {
await requireOrgAdmin(orgId, ctx.userId);
} catch {
return reply.code(403).send({ error: 'Admin access required' });
}
await cancelInvitation(invId);
reply.code(204).send();
});
}
+203
View File
@@ -0,0 +1,203 @@
import { randomBytes } from 'node:crypto';
import { and, eq, sql } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { insertAndGetById } from '../db/insertHelpers.js';
export type InvitationStatus = 'pending' | 'accepted' | 'cancelled' | 'expired';
export type InvitationRow = typeof schema.invitations.$inferSelect;
export type InvitationView = {
id: number;
orgId: number;
orgName: string;
email: string;
token: string;
role: string;
invitedByUsername: string;
status: InvitationStatus;
expiresAt: string | null;
createdAt: string | null;
};
function toInvitationView(row: Record<string, unknown>): InvitationView {
return {
id: Number(row.id),
orgId: Number(row.orgId),
orgName: String(row.orgName || ''),
email: String(row.email || ''),
token: String(row.token || ''),
role: String(row.role || ''),
invitedByUsername: String(row.invitedByUsername || ''),
status: (row.status as InvitationStatus) || 'pending',
expiresAt: (row.expiresAt as string) || null,
createdAt: (row.createdAt as string) || null,
};
}
export async function createInvitation(
orgId: number,
email: string,
role: 'admin' | 'member',
invitedBy: number,
): Promise<InvitationView> {
const token = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString();
const row = await insertAndGetById<InvitationRow>({
table: schema.invitations,
values: {
orgId,
email: email.trim().toLowerCase(),
token,
role,
invitedBy,
status: 'pending',
expiresAt,
},
idColumn: schema.invitations.id,
insertErrorMessage: 'Failed to create invitation',
});
// Fetch org name and inviter username for the view
const [org, user] = await Promise.all([
db.select({ name: schema.organizations.name })
.from(schema.organizations)
.where(eq(schema.organizations.id, orgId))
.get(),
db.select({ username: schema.users.username })
.from(schema.users)
.where(eq(schema.users.id, invitedBy))
.get(),
]);
return {
id: row.id,
orgId: row.orgId,
orgName: org?.name || '',
email: row.email,
token: row.token,
role: row.role,
invitedByUsername: user?.username || '',
status: (row.status as InvitationStatus) || 'pending',
expiresAt: row.expiresAt || null,
createdAt: row.createdAt || null,
};
}
export async function getInvitationByToken(token: string): Promise<InvitationView | null> {
const row = await db.select({
id: schema.invitations.id,
orgId: schema.invitations.orgId,
orgName: schema.organizations.name,
email: schema.invitations.email,
token: schema.invitations.token,
role: schema.invitations.role,
invitedByUsername: schema.users.username,
status: schema.invitations.status,
expiresAt: schema.invitations.expiresAt,
createdAt: schema.invitations.createdAt,
})
.from(schema.invitations)
.innerJoin(
schema.organizations,
eq(schema.invitations.orgId, schema.organizations.id),
)
.innerJoin(
schema.users,
eq(schema.invitations.invitedBy, schema.users.id),
)
.where(
and(
eq(schema.invitations.token, token),
eq(schema.invitations.status, 'pending'),
sql`${schema.invitations.expiresAt} > datetime('now')`,
),
)
.get();
if (!row) return null;
return toInvitationView(row as unknown as Record<string, unknown>);
}
export async function acceptInvitation(token: string, userId: number): Promise<number> {
const invitation = await db.select({
id: schema.invitations.id,
orgId: schema.invitations.orgId,
})
.from(schema.invitations)
.where(
and(
eq(schema.invitations.token, token),
eq(schema.invitations.status, 'pending'),
sql`${schema.invitations.expiresAt} > datetime('now')`,
),
)
.get();
if (!invitation) {
throw new Error('Invitation not found or expired');
}
await db.update(schema.invitations)
.set({ status: 'accepted' })
.where(eq(schema.invitations.id, invitation.id))
.run();
// Add user as member of the organization
await db.insert(schema.organizationMembers).values({
orgId: invitation.orgId,
userId,
role: 'member',
status: 'active',
}).run();
return invitation.orgId;
}
export async function cancelInvitation(id: number): Promise<void> {
await db.update(schema.invitations)
.set({ status: 'cancelled' })
.where(eq(schema.invitations.id, id))
.run();
}
export async function listOrgInvitations(orgId: number): Promise<InvitationView[]> {
const rows = await db.select({
id: schema.invitations.id,
orgId: schema.invitations.orgId,
orgName: schema.organizations.name,
email: schema.invitations.email,
token: schema.invitations.token,
role: schema.invitations.role,
invitedByUsername: schema.users.username,
status: schema.invitations.status,
expiresAt: schema.invitations.expiresAt,
createdAt: schema.invitations.createdAt,
})
.from(schema.invitations)
.innerJoin(
schema.organizations,
eq(schema.invitations.orgId, schema.organizations.id),
)
.innerJoin(
schema.users,
eq(schema.invitations.invitedBy, schema.users.id),
)
.where(eq(schema.invitations.orgId, orgId))
.all();
return rows.map((row: Record<string, unknown>) => toInvitationView(row));
}
export async function cleanExpiredInvitations(): Promise<void> {
await db.update(schema.invitations)
.set({ status: 'expired' })
.where(
and(
eq(schema.invitations.status, 'pending'),
sql`${schema.invitations.expiresAt} <= datetime('now')`,
),
)
.run();
}
+292
View File
@@ -0,0 +1,292 @@
import { and, eq, sql } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { insertAndGetById } from '../db/insertHelpers.js';
export type OrgRole = 'owner' | 'admin' | 'member';
export type OrgStatus = 'active' | 'disabled';
export type OrgPlan = 'free' | 'pro' | 'enterprise';
export type OrganizationRow = typeof schema.organizations.$inferSelect;
export type OrgMemberRow = typeof schema.organizationMembers.$inferSelect;
export type OrganizationView = {
id: number;
name: string;
slug: string;
plan: OrgPlan;
status: OrgStatus;
createdAt: string | null;
updatedAt: string | null;
memberCount?: number;
};
export type OrgMemberView = {
id: number;
orgId: number;
userId: number;
username: string;
email: string;
role: OrgRole;
status: OrgStatus;
joinedAt: string | null;
};
function toOrganizationView(row: OrganizationRow): OrganizationView {
return {
id: row.id,
name: row.name,
slug: row.slug,
plan: (row.plan as OrgPlan) || 'free',
status: (row.status as OrgStatus) || 'active',
createdAt: row.createdAt || null,
updatedAt: row.updatedAt || null,
};
}
function sanitizeSlug(raw: string): string {
return raw.toLowerCase().replace(/\s+/g, '-');
}
export async function createOrganization(
name: string,
slug: string,
creatorUserId: number,
): Promise<OrganizationView> {
const cleanSlug = sanitizeSlug(slug);
const row = await insertAndGetById<OrganizationRow>({
table: schema.organizations,
values: {
name: name.trim(),
slug: cleanSlug,
plan: 'free',
status: 'active',
},
idColumn: schema.organizations.id,
insertErrorMessage: 'Failed to create organization',
});
// Add creator as owner member
await db.insert(schema.organizationMembers).values({
orgId: row.id,
userId: creatorUserId,
role: 'owner',
status: 'active',
}).run();
return toOrganizationView(row);
}
export async function getOrganizationById(id: number): Promise<OrganizationView | null> {
const row = await db.select().from(schema.organizations)
.where(eq(schema.organizations.id, id))
.get();
if (!row) return null;
return toOrganizationView(row);
}
export async function getOrganizationBySlug(slug: string): Promise<OrganizationView | null> {
const row = await db.select().from(schema.organizations)
.where(eq(schema.organizations.slug, slug))
.get();
if (!row) return null;
return toOrganizationView(row);
}
export async function updateOrganization(
id: number,
input: { name?: string; slug?: string; plan?: OrgPlan; status?: OrgStatus },
): Promise<void> {
const updates: Record<string, unknown> = {};
if (input.name !== undefined) updates.name = input.name.trim();
if (input.slug !== undefined) updates.slug = sanitizeSlug(input.slug);
if (input.plan !== undefined) updates.plan = input.plan;
if (input.status !== undefined) updates.status = input.status;
updates.updatedAt = new Date().toISOString();
await db.update(schema.organizations)
.set(updates)
.where(eq(schema.organizations.id, id))
.run();
}
export async function listUserOrganizations(userId: number): Promise<OrganizationView[]> {
const rows = await db.select({
id: schema.organizations.id,
name: schema.organizations.name,
slug: schema.organizations.slug,
plan: schema.organizations.plan,
status: schema.organizations.status,
createdAt: schema.organizations.createdAt,
updatedAt: schema.organizations.updatedAt,
memberCount: sql<number>`(
SELECT COUNT(*) FROM ${schema.organizationMembers}
WHERE ${schema.organizationMembers.orgId} = ${schema.organizations.id}
AND ${schema.organizationMembers.status} = 'active'
)`,
})
.from(schema.organizations)
.innerJoin(
schema.organizationMembers,
eq(schema.organizations.id, schema.organizationMembers.orgId),
)
.where(
eq(schema.organizationMembers.userId, userId),
)
.all();
return rows.map((row: any) => ({
id: Number(row.id),
name: String(row.name || ''),
slug: String(row.slug || ''),
plan: (row.plan as OrgPlan) || 'free',
status: (row.status as OrgStatus) || 'active',
createdAt: (row.createdAt as string) || null,
updatedAt: (row.updatedAt as string) || null,
memberCount: Number(row.memberCount) || 0,
})) as OrganizationView[];
}
export async function listOrganizationMembers(orgId: number): Promise<OrgMemberView[]> {
const rows = await db.select({
id: schema.organizationMembers.id,
orgId: schema.organizationMembers.orgId,
userId: schema.organizationMembers.userId,
username: schema.users.username,
email: schema.users.email,
role: schema.organizationMembers.role,
status: schema.organizationMembers.status,
joinedAt: schema.organizationMembers.joinedAt,
})
.from(schema.organizationMembers)
.innerJoin(
schema.users,
eq(schema.organizationMembers.userId, schema.users.id),
)
.where(eq(schema.organizationMembers.orgId, orgId))
.all();
return rows.map((row: any) => ({
id: row.id,
orgId: row.orgId,
userId: row.userId,
username: row.username,
email: row.email,
role: (row.role as OrgRole) || 'member',
status: (row.status as OrgStatus) || 'active',
joinedAt: row.joinedAt || null,
}));
}
export async function addMember(
orgId: number,
userId: number,
role: OrgRole,
): Promise<OrgMemberView> {
const row = await insertAndGetById<OrgMemberRow>({
table: schema.organizationMembers,
values: {
orgId,
userId,
role,
status: 'active',
},
idColumn: schema.organizationMembers.id,
insertErrorMessage: 'Failed to add organization member',
});
// Fetch user info for the view
const user = await db.select({
username: schema.users.username,
email: schema.users.email,
})
.from(schema.users)
.where(eq(schema.users.id, userId))
.get();
return {
id: row.id,
orgId: row.orgId,
userId: row.userId,
username: user?.username || '',
email: user?.email || '',
role: (row.role as OrgRole) || 'member',
status: (row.status as OrgStatus) || 'active',
joinedAt: row.joinedAt || null,
};
}
export async function updateMemberRole(
orgId: number,
userId: number,
role: OrgRole,
): Promise<void> {
await db.update(schema.organizationMembers)
.set({ role })
.where(
and(
eq(schema.organizationMembers.orgId, orgId),
eq(schema.organizationMembers.userId, userId),
),
)
.run();
}
export async function removeMember(orgId: number, userId: number): Promise<void> {
await db.delete(schema.organizationMembers)
.where(
and(
eq(schema.organizationMembers.orgId, orgId),
eq(schema.organizationMembers.userId, userId),
),
)
.run();
}
export async function getMemberCount(orgId: number): Promise<number> {
const result = await db.select({
count: sql<number>`COUNT(*)`,
})
.from(schema.organizationMembers)
.where(eq(schema.organizationMembers.orgId, orgId))
.get();
return Number(result?.count || 0);
}
export async function isOrgMember(orgId: number, userId: number): Promise<boolean> {
const row = await db.select({ id: schema.organizationMembers.id })
.from(schema.organizationMembers)
.where(
and(
eq(schema.organizationMembers.orgId, orgId),
eq(schema.organizationMembers.userId, userId),
),
)
.get();
return !!row;
}
export async function checkPlanLimit(orgId: number): Promise<{ ok: boolean; limit: string | null }> {
const org = await db.select({ plan: schema.organizations.plan })
.from(schema.organizations)
.where(eq(schema.organizations.id, orgId))
.get();
if (!org) {
return { ok: false, limit: 'Organization not found' };
}
const planRow = await db.select({ maxMembers: schema.dataPlans.maxMembers })
.from(schema.dataPlans)
.where(eq(schema.dataPlans.name, org.plan))
.get();
if (!planRow) {
// No plan config found — allow by default
return { ok: true, limit: null };
}
const count = await getMemberCount(orgId);
if (count >= planRow.maxMembers) {
return { ok: false, limit: `Plan "${org.plan}" allows max ${planRow.maxMembers} members` };
}
return { ok: true, limit: null };
}
+77
View File
@@ -0,0 +1,77 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
export type PlanLimit = {
name: string;
maxMembers: number;
maxApiKeys: number;
maxRequestsPerDay: number;
};
const DEFAULT_FREE_PLAN: PlanLimit = {
name: 'free',
maxMembers: 3,
maxApiKeys: 5,
maxRequestsPerDay: 1000,
};
function toPlanLimit(row: Record<string, unknown>): PlanLimit {
return {
name: String(row.name || ''),
maxMembers: Number(row.maxMembers) || 0,
maxApiKeys: Number(row.maxApiKeys) || 0,
maxRequestsPerDay: Number(row.maxRequestsPerDay) || 0,
};
}
export async function getPlanLimits(planName: string): Promise<PlanLimit> {
const row = await db.select()
.from(schema.dataPlans)
.where(eq(schema.dataPlans.name, planName))
.get();
if (!row) {
// Return sensible defaults for known plan names
const defaults: Record<string, PlanLimit> = {
free: { name: 'free', maxMembers: 3, maxApiKeys: 5, maxRequestsPerDay: 1000 },
pro: { name: 'pro', maxMembers: 10, maxApiKeys: 20, maxRequestsPerDay: 10000 },
enterprise: { name: 'enterprise', maxMembers: 100, maxApiKeys: 100, maxRequestsPerDay: 100000 },
};
return defaults[planName] || { ...DEFAULT_FREE_PLAN };
}
return toPlanLimit(row as unknown as Record<string, unknown>);
}
export async function listAllPlans(): Promise<PlanLimit[]> {
const rows = await db.select().from(schema.dataPlans).all();
return rows.map((row: Record<string, unknown>) => toPlanLimit(row));
}
export async function seedDefaultPlans(): Promise<void> {
const plans: PlanLimit[] = [
{ name: 'free', maxMembers: 3, maxApiKeys: 5, maxRequestsPerDay: 1000 },
{ name: 'pro', maxMembers: 10, maxApiKeys: 20, maxRequestsPerDay: 10000 },
{ name: 'enterprise', maxMembers: 100, maxApiKeys: 100, maxRequestsPerDay: 100000 },
];
for (const plan of plans) {
const existing = await db.select({ id: schema.dataPlans.id })
.from(schema.dataPlans)
.where(eq(schema.dataPlans.name, plan.name))
.get();
if (existing) {
await db.update(schema.dataPlans)
.set({
maxMembers: plan.maxMembers,
maxApiKeys: plan.maxApiKeys,
maxRequestsPerDay: plan.maxRequestsPerDay,
})
.where(eq(schema.dataPlans.name, plan.name))
.run();
} else {
await db.insert(schema.dataPlans).values(plan).run();
}
}
}