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
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:
@@ -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'))`),
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user