prodobit
Version:
Open-core business application development platform
755 lines (687 loc) • 20.6 kB
text/typescript
import { Hono } from "hono";
import { eq, and, isNull, desc, gt, sql } from "drizzle-orm";
import {
users,
tenantMemberships,
roles,
tenants,
userInvitations,
authMethods,
} from "@prodobit/database";
import { authMiddleware } from "./middleware/auth.js";
import { requirePermission } from "./middleware/rbac.js";
import { TokenUtils } from "./utils/tokens.js";
import { EmailService } from "./utils/email.js";
const app = new Hono();
// Apply auth middleware to all routes
app.use("*", authMiddleware);
// Validation interfaces (using simple TypeScript validation instead of schema libraries)
interface CreateInvitationBody {
email: string;
role?: string; // Simple role string like "admin", "user", "manager"
roleId?: string; // Optional UUID for custom roles table (future use)
message?: string | null;
expiresInDays?: number;
membershipExpiresAt?: string | null;
accessLevel?: 'full' | 'limited' | 'read_only';
permissions?: Record<string, any>;
resourceRestrictions?: Record<string, any>;
}
interface UpdateMembershipBody {
roleId?: string;
status?: 'active' | 'inactive' | 'suspended';
accessLevel?: 'full' | 'limited';
expiresAt?: string;
permissions?: Record<string, any>;
resourceRestrictions?: Record<string, any>;
}
// Simple validation functions
function validateCreateInvitation(body: any): CreateInvitationBody {
if (!body.email || typeof body.email !== 'string') {
throw new Error('Valid email is required');
}
// Accept either role string or roleId
if (!body.role && !body.roleId) {
throw new Error('Valid role or roleId is required');
}
return {
email: body.email,
role: body.role,
roleId: body.roleId,
message: body.message || null,
expiresInDays: body.expiresInDays || 7,
membershipExpiresAt: body.membershipExpiresAt || null,
accessLevel: body.accessLevel || 'full',
permissions: body.permissions || {},
resourceRestrictions: body.resourceRestrictions || {},
};
}
function validateUpdateMembership(body: any): UpdateMembershipBody {
const result: UpdateMembershipBody = {};
if (body.roleId !== undefined) result.roleId = body.roleId;
if (body.status !== undefined) result.status = body.status;
if (body.accessLevel !== undefined) result.accessLevel = body.accessLevel;
if (body.expiresAt !== undefined) result.expiresAt = body.expiresAt;
if (body.permissions !== undefined) result.permissions = body.permissions;
if (body.resourceRestrictions !== undefined) result.resourceRestrictions = body.resourceRestrictions;
return result;
}
// Get tenant members
app.get(
"/tenants/:tenantId/members",
requirePermission("tenant_members", "read"),
async (c) => {
try {
const tenantId = c.req.param("tenantId");
const db = c.get("db");
const members = await db
.select({
membershipId: tenantMemberships.id,
userId: users.id,
displayName: users.displayName,
status: tenantMemberships.status,
roleId: tenantMemberships.roleId,
roleName: roles.name,
roleDescription: roles.description,
roleColor: roles.color,
accessLevel: tenantMemberships.accessLevel,
permissions: tenantMemberships.permissions,
resourceRestrictions: tenantMemberships.resourceRestrictions,
expiresAt: tenantMemberships.expiresAt,
joinedAt: tenantMemberships.joinedAt,
lastLoginAt: tenantMemberships.lastLoginAt,
invitedBy: tenantMemberships.invitedBy,
invitedAt: tenantMemberships.invitedAt,
})
.from(tenantMemberships)
.innerJoin(users, sql`${users.id} = ${tenantMemberships.userId}`)
.innerJoin(roles, eq(roles.id, tenantMemberships.roleId))
.where(
and(
sql`${tenantMemberships.tenantId} = ${tenantId}::uuid`,
isNull(tenantMemberships.deletedAt)
)
)
.orderBy(desc(tenantMemberships.joinedAt));
return c.json({
success: true,
data: members,
});
} catch (error) {
console.error("Error fetching tenant members:", error);
return c.json(
{
success: false,
error: "Failed to fetch tenant members",
},
500
);
}
}
);
// Get tenant roles
app.get(
"/tenants/:tenantId/roles",
requirePermission("tenant_roles", "read"),
async (c) => {
try {
const tenantId = c.req.param("tenantId");
const db = c.get("db");
const tenantRoles = await db
.select({
id: roles.id,
name: roles.name,
description: roles.description,
isSystem: roles.isSystem,
isActive: roles.isActive,
color: roles.color,
})
.from(roles)
.where(
and(
sql`${roles.tenantId} = ${tenantId}::uuid`,
eq(roles.isActive, true),
isNull(roles.deletedAt)
)
)
.orderBy(roles.name);
return c.json({
success: true,
data: tenantRoles,
});
} catch (error) {
console.error("Error fetching tenant roles:", error);
return c.json(
{
success: false,
error: "Failed to fetch tenant roles",
},
500
);
}
}
);
// Create user invitation
app.post(
"/tenants/:tenantId/invitations",
requirePermission("tenant_members", "create"),
async (c) => {
try {
const tenantId = c.req.param("tenantId");
const user = c.get("user");
const invitedBy = user?.id;
const body = await c.req.json();
if (!invitedBy) {
return c.json(
{
success: false,
error: "Authentication required - user ID not found",
},
401
);
}
let data: CreateInvitationBody;
try {
data = validateCreateInvitation(body);
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : "Invalid request data",
},
400
);
}
const db = c.get("db");
// If role string is provided instead of roleId, convert it
let roleId = data.roleId;
if (!roleId && data.role) {
const roleResult = await db
.select()
.from(roles)
.where(
and(
eq(roles.tenantId, tenantId),
eq(roles.name, data.role)
)
)
.limit(1);
if (roleResult.length === 0) {
return c.json(
{
success: false,
error: `Role '${data.role}' not found for this tenant`,
},
400
);
}
roleId = roleResult[0].id;
}
if (!roleId) {
return c.json(
{
success: false,
error: "Valid roleId is required",
},
400
);
}
// Check if user already has a pending invitation for this tenant
const existingInvitation = await db
.select()
.from(userInvitations)
.where(
and(
sql`${userInvitations.tenantId} = ${tenantId}::uuid`,
eq(userInvitations.email, data.email),
eq(userInvitations.status, "pending"),
isNull(userInvitations.deletedAt)
)
)
.limit(1);
if (existingInvitation.length > 0) {
return c.json(
{
success: false,
error: "User already has a pending invitation for this tenant",
},
400
);
}
// Generate secure token
const token = TokenUtils.generateSecureToken();
// Get tenant, role, and inviter details for email
const invitationData = await db
.select({
tenantName: tenants.name,
roleName: roles.name,
inviterName: users.displayName,
})
.from(tenants)
.innerJoin(roles, sql`${roles.id} = ${roleId}::uuid AND ${roles.tenantId} = ${tenantId}::uuid`)
.innerJoin(users, eq(users.id, invitedBy))
.where(sql`${tenants.id} = ${tenantId}::uuid`)
.limit(1);
if (invitationData.length === 0) {
return c.json(
{
success: false,
error: "Invalid tenant, role, or inviter",
},
400
);
}
const { tenantName, roleName, inviterName } = invitationData[0];
// Create invitation record
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + (data.expiresInDays ?? 7));
const [invitation] = await db
.insert(userInvitations)
.values({
tenantId,
email: data.email,
roleId: roleId,
invitedBy,
token,
message: data.message,
expiresAt,
membershipExpiresAt: data.membershipExpiresAt ? new Date(data.membershipExpiresAt) : null,
permissions: data.permissions,
accessLevel: data.accessLevel,
resourceRestrictions: data.resourceRestrictions,
})
.returning();
// Generate invitation URL
const baseUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const invitationUrl = `${baseUrl}/accept-invitation?token=${token}`;
// Send invitation email
const emailResult = await EmailService.sendInvitationEmail({
email: data.email,
inviterName: inviterName || "Unknown",
tenantName: tenantName || "Unknown Organization",
roleName: roleName || "Unknown Role",
invitationUrl,
message: data.message ?? undefined,
expiresInDays: data.expiresInDays ?? 7,
});
if (!emailResult.success) {
// Rollback invitation if email fails
await db
.update(userInvitations)
.set({ deletedAt: new Date() })
.where(eq(userInvitations.id, invitation.id));
return c.json(
{
success: false,
error: `Failed to send invitation email: ${emailResult.error}`,
},
500
);
}
return c.json({
success: true,
data: {
id: invitation.id,
email: invitation.email,
status: invitation.status,
roleName,
inviterName: inviterName || "Unknown",
tenantName: tenantName || "Unknown Organization",
token: invitation.token,
expiresAt: invitation.expiresAt,
insertedAt: invitation.insertedAt,
message: invitation.message,
},
});
} catch (error) {
console.error("Error creating invitation:", error);
return c.json(
{
success: false,
error: "Failed to create invitation",
},
500
);
}
}
);
// Get tenant invitations
app.get(
"/tenants/:tenantId/invitations",
requirePermission("tenant_members", "read"),
async (c) => {
try {
const tenantId = c.req.param("tenantId");
const db = c.get("db");
const invitations = await db
.select({
id: userInvitations.id,
email: userInvitations.email,
status: userInvitations.status,
token: userInvitations.token,
expiresAt: userInvitations.expiresAt,
insertedAt: userInvitations.insertedAt,
message: userInvitations.message,
roleName: roles.name,
inviterName: users.displayName,
tenantName: tenants.name,
})
.from(userInvitations)
.innerJoin(roles, eq(roles.id, userInvitations.roleId))
.innerJoin(users, eq(users.id, userInvitations.invitedBy))
.innerJoin(tenants, sql`${tenants.id} = ${userInvitations.tenantId}`)
.where(
and(
sql`${userInvitations.tenantId} = ${tenantId}::uuid`,
isNull(userInvitations.deletedAt)
)
)
.orderBy(userInvitations.insertedAt);
return c.json({
success: true,
data: invitations.map(inv => ({
...inv,
inviterName: inv.inviterName || "Unknown",
tenantName: inv.tenantName || "Unknown Organization",
})),
});
} catch (error) {
console.error("Error fetching invitations:", error);
return c.json(
{
success: false,
error: "Failed to fetch invitations",
},
500
);
}
}
);
// Update tenant membership
app.patch(
"/tenants/:tenantId/members/:membershipId",
requirePermission("tenant_members", "update"),
async (c) => {
try {
const tenantId = c.req.param("tenantId");
const membershipId = c.req.param("membershipId");
const body = await c.req.json();
let data: UpdateMembershipBody;
try {
data = validateUpdateMembership(body);
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : "Invalid request data",
},
400
);
}
const db = c.get("db");
const updateData: any = {
updatedAt: new Date(),
};
if (data.roleId) updateData.roleId = data.roleId;
if (data.status) updateData.status = data.status;
if (data.accessLevel) updateData.accessLevel = data.accessLevel;
if (data.expiresAt) updateData.expiresAt = new Date(data.expiresAt);
if (data.permissions) updateData.permissions = data.permissions;
if (data.resourceRestrictions) updateData.resourceRestrictions = data.resourceRestrictions;
const [updatedMembership] = await db
.update(tenantMemberships)
.set(updateData)
.where(
and(
eq(tenantMemberships.id, membershipId),
sql`${tenantMemberships.tenantId} = ${tenantId}::uuid`,
isNull(tenantMemberships.deletedAt)
)
)
.returning();
if (!updatedMembership) {
return c.json(
{
success: false,
error: "Membership not found",
},
404
);
}
return c.json({
success: true,
data: updatedMembership,
});
} catch (error) {
console.error("Error updating membership:", error);
return c.json(
{
success: false,
error: "Failed to update membership",
},
500
);
}
}
);
// Remove tenant member
app.delete(
"/tenants/:tenantId/members/:membershipId",
requirePermission("tenant_members", "delete"),
async (c) => {
try {
const tenantId = c.req.param("tenantId");
const membershipId = c.req.param("membershipId");
const db = c.get("db");
const [deletedMembership] = await db
.update(tenantMemberships)
.set({
deletedAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(tenantMemberships.id, membershipId),
sql`${tenantMemberships.tenantId} = ${tenantId}::uuid`,
isNull(tenantMemberships.deletedAt)
)
)
.returning();
if (!deletedMembership) {
return c.json(
{
success: false,
error: "Membership not found",
},
404
);
}
return c.json({
success: true,
message: "Member removed successfully",
});
} catch (error) {
console.error("Error removing member:", error);
return c.json(
{
success: false,
error: "Failed to remove member",
},
500
);
}
}
);
// Public route: Get invitation details by token
app.get("/invitations/:token", async (c) => {
try {
const token = c.req.param("token");
const db = c.get("db");
console.log('=== DEBUG: Get invitation by token ===');
console.log('Token:', token);
console.log('=====================================');
const invitationData = await db
.select({
id: userInvitations.id,
email: userInvitations.email,
status: userInvitations.status,
token: userInvitations.token,
expiresAt: userInvitations.expiresAt,
insertedAt: userInvitations.insertedAt,
message: userInvitations.message,
roleName: roles.name,
inviterName: users.displayName,
tenantName: tenants.name,
})
.from(userInvitations)
.innerJoin(roles, eq(roles.id, userInvitations.roleId))
.innerJoin(users, eq(users.id, userInvitations.invitedBy))
.innerJoin(tenants, sql`${tenants.id} = ${userInvitations.tenantId}`)
.where(
and(
eq(userInvitations.token, token),
isNull(userInvitations.deletedAt)
)
)
.limit(1);
console.log('Query result count:', invitationData.length);
if (invitationData.length > 0) {
console.log('Found invitation:', {
id: invitationData[0].id,
email: invitationData[0].email,
status: invitationData[0].status,
});
}
if (invitationData.length === 0) {
console.log('No invitation found with token:', token);
return c.json(
{
success: false,
error: "Invitation not found",
},
404
);
}
const invitation = invitationData[0];
return c.json({
success: true,
data: {
...invitation,
inviterName: invitation.inviterName || "Unknown",
tenantName: invitation.tenantName || "Unknown Organization",
},
});
} catch (error) {
console.error("Error fetching invitation:", error);
return c.json(
{
success: false,
error: "Failed to fetch invitation",
},
500
);
}
});
// Public route: Accept invitation
app.post("/invitations/:token/accept", authMiddleware, async (c) => {
try {
const token = c.req.param("token");
const user = c.get("user");
const userId = user?.id;
const db = c.get("db");
if (!userId) {
return c.json(
{
success: false,
error: "Authentication required",
},
401
);
}
// Find and validate invitation
const invitation = await db
.select()
.from(userInvitations)
.where(
and(
eq(userInvitations.token, token),
eq(userInvitations.status, "pending"),
gt(userInvitations.expiresAt, new Date()),
isNull(userInvitations.deletedAt)
)
)
.limit(1);
if (invitation.length === 0) {
return c.json(
{
success: false,
error: "Invalid or expired invitation",
},
400
);
}
const inv = invitation[0];
// Check if user already has membership
const existingMembership = await db
.select()
.from(tenantMemberships)
.where(
and(
sql`${tenantMemberships.userId} = ${userId}::uuid`,
sql`${tenantMemberships.tenantId} = ${inv.tenantId}`,
isNull(tenantMemberships.deletedAt)
)
)
.limit(1);
if (existingMembership.length > 0) {
return c.json(
{
success: false,
error: "User is already a member of this tenant",
},
400
);
}
// Create tenant membership
const [membership] = await db
.insert(tenantMemberships)
.values({
userId,
tenantId: inv.tenantId,
roleId: inv.roleId,
permissions: inv.permissions,
accessLevel: inv.accessLevel,
resourceRestrictions: inv.resourceRestrictions,
expiresAt: inv.membershipExpiresAt,
invitedBy: inv.invitedBy,
invitedAt: inv.insertedAt,
joinedAt: new Date(),
status: "active",
})
.returning();
// Mark invitation as accepted
await db
.update(userInvitations)
.set({
status: "accepted",
acceptedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(userInvitations.id, inv.id));
return c.json({
success: true,
data: membership,
message: "Invitation accepted successfully",
});
} catch (error) {
console.error("Error accepting invitation:", error);
return c.json(
{
success: false,
error: "Failed to accept invitation",
},
500
);
}
});
export default app;