feat: adds group projects table

This commit is contained in:
Piyush Gupta
2025-11-20 15:05:21 +05:30
parent 59d43ed2eb
commit 844093d89f
16 changed files with 848 additions and 10 deletions
+51 -2
View File
@@ -1,7 +1,7 @@
import { z } from "zod";
import { GroupsSchema, OrgMembershipRole, UsersSchema } from "@app/db/schemas";
import { EFilterReturnedUsers } from "@app/ee/services/group/group-types";
import { GroupsSchema, OrgMembershipRole, ProjectsSchema, UsersSchema } from "@app/db/schemas";
import { EFilterReturnedProjects, EFilterReturnedUsers } from "@app/ee/services/group/group-types";
import { ApiDocsTags, GROUPS } from "@app/lib/api-docs";
import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -203,6 +203,55 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "GET",
url: "/:id/projects",
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
hide: false,
tags: [ApiDocsTags.Groups],
params: z.object({
id: z.string().trim().describe(GROUPS.LIST_PROJECTS.id)
}),
querystring: z.object({
offset: z.coerce.number().min(0).max(100).default(0).describe(GROUPS.LIST_PROJECTS.offset),
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_PROJECTS.limit),
search: z.string().trim().optional().describe(GROUPS.LIST_PROJECTS.search),
filter: z.nativeEnum(EFilterReturnedProjects).optional().describe(GROUPS.LIST_PROJECTS.filterProjects)
}),
response: {
200: z.object({
projects: ProjectsSchema.pick({
id: true,
name: true,
slug: true,
description: true,
type: true
})
.merge(
z.object({
joinedGroupAt: z.date().nullable()
})
)
.array(),
totalCount: z.number()
})
}
},
handler: async (req) => {
const { projects, totalCount } = await server.services.group.listGroupProjects({
id: req.params.id,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.query
});
return { projects, totalCount };
}
});
server.route({
method: "POST",
url: "/:id/users/:username",
+77 -1
View File
@@ -5,7 +5,7 @@ import { AccessScope, TableName, TGroups } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex";
import { EFilterReturnedUsers } from "./group-types";
import { EFilterReturnedProjects, EFilterReturnedUsers } from "./group-types";
export type TGroupDALFactory = ReturnType<typeof groupDALFactory>;
@@ -166,6 +166,81 @@ export const groupDALFactory = (db: TDbClient) => {
}
};
const findAllGroupProjects = async ({
orgId,
groupId,
offset,
limit,
search,
filter
}: {
orgId: string;
groupId: string;
offset?: number;
limit?: number;
search?: string;
filter?: EFilterReturnedProjects;
}) => {
try {
const normalizedSearch = search?.trim();
const query = db
.replicaNode()(TableName.Project)
.where(`${TableName.Project}.orgId`, orgId)
.leftJoin(TableName.Membership, (bd) => {
bd.on(`${TableName.Project}.id`, "=", `${TableName.Membership}.scopeProjectId`)
.andOn(`${TableName.Membership}.actorGroupId`, "=", db.raw("?", [groupId]))
.andOn(`${TableName.Membership}.scope`, "=", db.raw("?", [AccessScope.Project]));
})
.select(
db.ref("id").withSchema(TableName.Project),
db.ref("name").withSchema(TableName.Project),
db.ref("slug").withSchema(TableName.Project),
db.ref("description").withSchema(TableName.Project),
db.ref("type").withSchema(TableName.Project),
db.ref("createdAt").withSchema(TableName.Membership).as("joinedGroupAt"),
db.raw(`count(*) OVER() as "totalCount"`)
)
.orderBy(`${TableName.Project}.name`, "asc")
.offset(offset ?? 0);
if (limit) {
void query.limit(limit);
}
if (normalizedSearch && normalizedSearch.toLowerCase() !== "undefined") {
void query.andWhereRaw(
`CONCAT_WS(' ', "${TableName.Project}"."name", "${TableName.Project}"."slug", "${TableName.Project}"."description") ilike ?`,
[`%${normalizedSearch}%`]
);
}
switch (filter) {
case EFilterReturnedProjects.ASSIGNED_PROJECTS:
void query.whereNotNull(`${TableName.Membership}.id`);
break;
case EFilterReturnedProjects.UNASSIGNED_PROJECTS:
void query.whereNull(`${TableName.Membership}.id`);
break;
default:
break;
}
const projects = await query;
return {
projects: projects.map(({ joinedGroupAt, ...project }) => ({
...project,
joinedGroupAt
})),
// @ts-expect-error col select is raw and not strongly typed
totalCount: Number(projects?.[0]?.totalCount ?? 0)
};
} catch (error) {
throw new DatabaseError({ error, name: "Find all group projects" });
}
};
const findGroupsByProjectId = async (projectId: string, tx?: Knex) => {
try {
const docs = await (tx || db.replicaNode())(TableName.Groups)
@@ -230,6 +305,7 @@ export const groupDALFactory = (db: TDbClient) => {
findGroups,
findByOrgId,
findAllGroupPossibleMembers,
findAllGroupProjects,
findGroupsByProjectId,
findById,
findOne
+55 -1
View File
@@ -24,6 +24,7 @@ import {
TCreateGroupDTO,
TDeleteGroupDTO,
TGetGroupByIdDTO,
TListGroupProjectsDTO,
TListGroupUsersDTO,
TRemoveUserFromGroupDTO,
TUpdateGroupDTO
@@ -34,7 +35,14 @@ type TGroupServiceFactoryDep = {
userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction" | "findUserByUsername">;
groupDAL: Pick<
TGroupDALFactory,
"create" | "findOne" | "update" | "delete" | "findAllGroupPossibleMembers" | "findById" | "transaction"
| "create"
| "findOne"
| "update"
| "delete"
| "findAllGroupPossibleMembers"
| "findById"
| "transaction"
| "findAllGroupProjects"
>;
membershipGroupDAL: Pick<TMembershipGroupDALFactory, "find" | "findOne" | "create">;
membershipRoleDAL: Pick<TMembershipRoleDALFactory, "create" | "delete">;
@@ -367,6 +375,51 @@ export const groupServiceFactory = ({
return { users: members, totalCount };
};
const listGroupProjects = async ({
id,
offset,
limit,
search,
filter,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TListGroupProjectsDTO) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
const { permission } = await permissionService.getOrgPermission({
scope: OrganizationActionScope.Any,
actor,
actorId,
orgId: actorOrgId,
actorAuthMethod,
actorOrgId
});
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups);
const group = await groupDAL.findOne({
orgId: actorOrgId,
id
});
if (!group)
throw new NotFoundError({
message: `Failed to find group with ID ${id}`
});
const { projects, totalCount } = await groupDAL.findAllGroupProjects({
orgId: group.orgId,
groupId: group.id,
offset,
limit,
search,
filter
});
return { projects, totalCount };
};
const addUserToGroup = async ({ id, username, actor, actorId, actorAuthMethod, actorOrgId }: TAddUserToGroupDTO) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
@@ -535,6 +588,7 @@ export const groupServiceFactory = ({
updateGroup,
deleteGroup,
listGroupUsers,
listGroupProjects,
addUserToGroup,
removeUserFromGroup,
getGroupById
@@ -42,6 +42,14 @@ export type TListGroupUsersDTO = {
filter?: EFilterReturnedUsers;
} & TGenericPermission;
export type TListGroupProjectsDTO = {
id: string;
offset: number;
limit: number;
search?: string;
filter?: EFilterReturnedProjects;
} & TGenericPermission;
export type TListProjectGroupUsersDTO = TListGroupUsersDTO & {
projectId: string;
};
@@ -111,3 +119,8 @@ export enum EFilterReturnedUsers {
EXISTING_MEMBERS = "existingMembers",
NON_MEMBERS = "nonMembers"
}
export enum EFilterReturnedProjects {
ASSIGNED_PROJECTS = "assignedProjects",
UNASSIGNED_PROJECTS = "unassignedProjects"
}