Skip to content

Commit 5e2f545

Browse files
committed
feat(api, web): implement organization membership management features
1 parent 1c1e8ec commit 5e2f545

6 files changed

Lines changed: 1161 additions & 9 deletions

File tree

apps/api/src/db/queries.ts

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1701,3 +1701,275 @@ export async function deleteOrganization(
17011701

17021702
return !!deletedOrganization;
17031703
}
1704+
1705+
/**
1706+
* Add or update a user's membership in an organization (only admins and owners can do this)
1707+
*
1708+
* @param db Database instance
1709+
* @param organizationIdOrHandle Organization ID or handle
1710+
* @param targetUserId User ID to add/update membership for
1711+
* @param role Role to assign (member, admin, owner)
1712+
* @param adminUserId User ID of the admin/owner making the change
1713+
* @returns The created or updated membership record, or null if permission denied
1714+
*/
1715+
export async function addOrUpdateMembership(
1716+
db: ReturnType<typeof createDatabase>,
1717+
organizationIdOrHandle: string,
1718+
targetUserId: string,
1719+
role: OrganizationRoleType,
1720+
adminUserId: string
1721+
): Promise<MembershipRow | null> {
1722+
// First, verify the admin user has permission (admin or owner)
1723+
const [adminMembership] = await db
1724+
.select()
1725+
.from(memberships)
1726+
.innerJoin(organizations, eq(memberships.organizationId, organizations.id))
1727+
.where(
1728+
and(
1729+
eq(memberships.userId, adminUserId),
1730+
getOrganizationCondition(organizationIdOrHandle),
1731+
inArray(memberships.role, [
1732+
OrganizationRole.ADMIN,
1733+
OrganizationRole.OWNER,
1734+
])
1735+
)
1736+
)
1737+
.limit(1);
1738+
1739+
if (!adminMembership) {
1740+
return null; // Admin user doesn't have permission
1741+
}
1742+
1743+
// Additional check: only owners can assign the owner role
1744+
if (
1745+
role === OrganizationRole.OWNER &&
1746+
adminMembership.memberships.role !== OrganizationRole.OWNER
1747+
) {
1748+
return null; // Only owners can assign owner role
1749+
}
1750+
1751+
const organizationId = adminMembership.organizations.id;
1752+
const now = new Date();
1753+
1754+
// CRITICAL: Prevent changing the role of the main owner in their personal organization
1755+
// Check if the target user is the main owner of their personal organization
1756+
const [targetUser] = await db
1757+
.select()
1758+
.from(users)
1759+
.where(eq(users.id, targetUserId))
1760+
.limit(1);
1761+
1762+
if (targetUser && targetUser.organizationId === organizationId) {
1763+
// This is the user's personal organization - their role cannot be changed
1764+
return null; // Cannot change role of main owner in their personal organization
1765+
}
1766+
1767+
// Check if the target user is already a member
1768+
const [existingMembership] = await db
1769+
.select()
1770+
.from(memberships)
1771+
.where(
1772+
and(
1773+
eq(memberships.userId, targetUserId),
1774+
eq(memberships.organizationId, organizationId)
1775+
)
1776+
)
1777+
.limit(1);
1778+
1779+
if (existingMembership) {
1780+
// Update existing membership
1781+
const [updatedMembership] = await db
1782+
.update(memberships)
1783+
.set({
1784+
role,
1785+
updatedAt: now,
1786+
})
1787+
.where(
1788+
and(
1789+
eq(memberships.userId, targetUserId),
1790+
eq(memberships.organizationId, organizationId)
1791+
)
1792+
)
1793+
.returning();
1794+
1795+
return updatedMembership;
1796+
} else {
1797+
// Create new membership
1798+
const newMembership: MembershipInsert = {
1799+
userId: targetUserId,
1800+
organizationId,
1801+
role,
1802+
createdAt: now,
1803+
updatedAt: now,
1804+
};
1805+
1806+
const [createdMembership] = await db
1807+
.insert(memberships)
1808+
.values(newMembership)
1809+
.returning();
1810+
1811+
return createdMembership;
1812+
}
1813+
}
1814+
1815+
/**
1816+
* Delete a user's membership from an organization (only admins and owners can do this)
1817+
*
1818+
* @param db Database instance
1819+
* @param organizationIdOrHandle Organization ID or handle
1820+
* @param targetUserId User ID to remove from the organization
1821+
* @param adminUserId User ID of the admin/owner making the change
1822+
* @returns True if membership was deleted, false if permission denied or not found
1823+
*/
1824+
export async function deleteMembership(
1825+
db: ReturnType<typeof createDatabase>,
1826+
organizationIdOrHandle: string,
1827+
targetUserId: string,
1828+
adminUserId: string
1829+
): Promise<boolean> {
1830+
// First, verify the admin user has permission (admin or owner)
1831+
const [adminMembership] = await db
1832+
.select()
1833+
.from(memberships)
1834+
.innerJoin(organizations, eq(memberships.organizationId, organizations.id))
1835+
.where(
1836+
and(
1837+
eq(memberships.userId, adminUserId),
1838+
getOrganizationCondition(organizationIdOrHandle),
1839+
inArray(memberships.role, [
1840+
OrganizationRole.ADMIN,
1841+
OrganizationRole.OWNER,
1842+
])
1843+
)
1844+
)
1845+
.limit(1);
1846+
1847+
if (!adminMembership) {
1848+
return false; // Admin user doesn't have permission
1849+
}
1850+
1851+
// Get the target user's membership to check their role
1852+
const [targetMembership] = await db
1853+
.select()
1854+
.from(memberships)
1855+
.where(
1856+
and(
1857+
eq(memberships.userId, targetUserId),
1858+
eq(memberships.organizationId, adminMembership.organizations.id)
1859+
)
1860+
)
1861+
.limit(1);
1862+
1863+
if (!targetMembership) {
1864+
return false; // Target user is not a member
1865+
}
1866+
1867+
// Additional check: only owners can delete other owners
1868+
if (
1869+
targetMembership.role === OrganizationRole.OWNER &&
1870+
adminMembership.memberships.role !== OrganizationRole.OWNER
1871+
) {
1872+
return false; // Only owners can delete other owners
1873+
}
1874+
1875+
// Prevent users from deleting themselves
1876+
if (targetUserId === adminUserId) {
1877+
return false; // Users cannot delete their own membership
1878+
}
1879+
1880+
// CRITICAL: Prevent removing the main owner from their personal organization
1881+
// Check if the target user is the main owner of their personal organization
1882+
const [targetUser] = await db
1883+
.select()
1884+
.from(users)
1885+
.where(eq(users.id, targetUserId))
1886+
.limit(1);
1887+
1888+
if (
1889+
targetUser &&
1890+
targetUser.organizationId === adminMembership.organizations.id
1891+
) {
1892+
// This is the user's personal organization - they cannot be removed as the main owner
1893+
return false; // Cannot remove main owner from their personal organization
1894+
}
1895+
1896+
// Delete the membership
1897+
const [deletedMembership] = await db
1898+
.delete(memberships)
1899+
.where(
1900+
and(
1901+
eq(memberships.userId, targetUserId),
1902+
eq(memberships.organizationId, adminMembership.organizations.id)
1903+
)
1904+
)
1905+
.returning({ id: memberships.userId });
1906+
1907+
return !!deletedMembership;
1908+
}
1909+
1910+
/**
1911+
* List all memberships for an organization
1912+
*
1913+
* @param db Database instance
1914+
* @param organizationIdOrHandle Organization ID or handle
1915+
* @returns Array of membership records
1916+
*/
1917+
export async function getOrganizationMemberships(
1918+
db: ReturnType<typeof createDatabase>,
1919+
organizationIdOrHandle: string
1920+
) {
1921+
return await db
1922+
.select({
1923+
userId: memberships.userId,
1924+
organizationId: memberships.organizationId,
1925+
role: memberships.role,
1926+
createdAt: memberships.createdAt,
1927+
updatedAt: memberships.updatedAt,
1928+
})
1929+
.from(memberships)
1930+
.innerJoin(organizations, eq(memberships.organizationId, organizations.id))
1931+
.where(getOrganizationCondition(organizationIdOrHandle))
1932+
.orderBy(memberships.createdAt);
1933+
}
1934+
1935+
/**
1936+
* List all memberships for an organization with user information
1937+
*
1938+
* @param db Database instance
1939+
* @param organizationIdOrHandle Organization ID or handle
1940+
* @returns Array of membership records with user details
1941+
*/
1942+
export async function getOrganizationMembershipsWithUsers(
1943+
db: ReturnType<typeof createDatabase>,
1944+
organizationIdOrHandle: string
1945+
) {
1946+
const results = await db
1947+
.select({
1948+
userId: memberships.userId,
1949+
organizationId: memberships.organizationId,
1950+
role: memberships.role,
1951+
createdAt: memberships.createdAt,
1952+
updatedAt: memberships.updatedAt,
1953+
user: {
1954+
id: users.id,
1955+
name: users.name,
1956+
email: users.email,
1957+
avatarUrl: users.avatarUrl,
1958+
},
1959+
})
1960+
.from(memberships)
1961+
.innerJoin(organizations, eq(memberships.organizationId, organizations.id))
1962+
.innerJoin(users, eq(memberships.userId, users.id))
1963+
.where(getOrganizationCondition(organizationIdOrHandle))
1964+
.orderBy(memberships.createdAt);
1965+
1966+
// Convert null values to undefined to match TypeScript interface
1967+
return results.map((result) => ({
1968+
...result,
1969+
user: {
1970+
...result.user,
1971+
email: result.user.email ?? undefined,
1972+
avatarUrl: result.user.avatarUrl ?? undefined,
1973+
},
1974+
}));
1975+
}

0 commit comments

Comments
 (0)