diff --git a/prisma/migrations/20260225223034_add_community_metadata/migration.sql b/prisma/migrations/20260225223034_add_community_metadata/migration.sql new file mode 100644 index 0000000..a466293 --- /dev/null +++ b/prisma/migrations/20260225223034_add_community_metadata/migration.sql @@ -0,0 +1,42 @@ +-- CreateTable +CREATE TABLE "CommunityMetadata" ( + "id" TEXT NOT NULL, + "communityId" TEXT NOT NULL, + "communityName" TEXT NOT NULL, + "description" TEXT, + "image" TEXT, + "subdomains" TEXT[], + "groupIds" TEXT[], + "authorizedGroupIds" TEXT[], + "terms" INTEGER[], + "hidden" BOOLEAN NOT NULL DEFAULT false, + "hideSearch" BOOLEAN NOT NULL DEFAULT false, + "hideFilter" BOOLEAN NOT NULL DEFAULT false, + "chevronOverAvatar" BOOLEAN NOT NULL DEFAULT false, + "footerText" TEXT, + "leaderboardApiUrl" TEXT, + "newsFeed" TEXT, + "challengeFilter" JSONB, + "challengeListing" JSONB, + "menuItems" JSONB NOT NULL, + "logos" JSONB NOT NULL, + "additionalLogos" TEXT[], + "accessDeniedPage" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CommunityMetadata_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CommunityMetadata_communityId_key" ON "CommunityMetadata"("communityId"); + +-- CreateIndex +CREATE INDEX "CommunityMetadata_communityId_idx" ON "CommunityMetadata"("communityId"); + +-- CreateIndex +CREATE INDEX "CommunityMetadata_hidden_idx" ON "CommunityMetadata"("hidden"); + +-- CreateIndex +CREATE INDEX "CommunityMetadata_subdomains_idx" ON "CommunityMetadata"("subdomains"); + diff --git a/prisma/migrations/20260225223625_add_community_metadata_metadata_json/migration.sql b/prisma/migrations/20260225223625_add_community_metadata_metadata_json/migration.sql new file mode 100644 index 0000000..164e7de --- /dev/null +++ b/prisma/migrations/20260225223625_add_community_metadata_metadata_json/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "CommunityMetadata" +ADD COLUMN "metadata" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ea7bb3b..2fe719b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -82,3 +82,35 @@ model User { @@index([universalUID]) } + +model CommunityMetadata { + id String @id @default(uuid()) + communityId String @unique + communityName String + description String? + image String? + subdomains String[] + groupIds String[] + authorizedGroupIds String[] + terms Int[] + hidden Boolean @default(false) + hideSearch Boolean @default(false) + hideFilter Boolean @default(false) + chevronOverAvatar Boolean @default(false) + footerText String? + leaderboardApiUrl String? + newsFeed String? + challengeFilter Json? + challengeListing Json? + metadata Json? + menuItems Json + logos Json + additionalLogos String[] + accessDeniedPage Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([communityId]) + @@index([hidden]) + @@index([subdomains]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts index a8bdf68..eab5c6c 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,485 +1,138 @@ -import { PrismaClient, Prisma } from '@prisma/client'; +import { Prisma, PrismaClient } from '@prisma/client'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; const prisma = new PrismaClient(); -const groupData: Prisma.GroupCreateManyInput[] = [ - { - id: '2fd8ba9f-e229-40f8-9f3e-6a75aba7c8f1', - name: 'Wipro - Topgear - Group_265111', - description: 'Wipro Topgear group for internal challenges', - organizationId: '111', - domain: '', - ssoId: '', - oldId: '20000014', - privateGroup: false, - selfRegister: false, - status: 'active', - createdBy: '00000000', - createdAt: '2019-10-11T07:21:13.326Z', - updatedBy: '00000000', - updatedAt: '2020-11-06T14:52:25.864Z', - }, - { - id: '546bb184-1338-4979-b4a4-f0e82e2602a8', - ssoId: '', - updatedBy: '00000000', - description: 'AAA', - privateGroup: false, - oldId: '20000013', - organizationId: '222', - createdAt: '2020-11-01T05:44:17.593Z', - selfRegister: false, - createdBy: '00000000', - domain: '', - name: 'Wipro - Topgear - AABB', - updatedAt: '2020-11-01T07:32:40.691Z', - status: 'inactive', - }, - { - id: 'fabcb683-d749-47da-81f4-91f5f80a8571', - ssoId: '', - updatedBy: '00000000', - description: 'BBBB', - privateGroup: false, - oldId: '', - organizationId: '333', - createdAt: '2020-11-01T05:44:26.546Z', - selfRegister: false, - createdBy: '00000000', - domain: '', - name: 'Wipro - Topgear - BBB', - updatedAt: '2020-11-01T05:45:50.048Z', - status: 'inactive', - }, - { - id: '0eda0b91-3cc5-4778-8e5f-1d9a67ecae12', - ssoId: '', - updatedBy: '40029484', - description: 'test group api', - privateGroup: false, - oldId: '', - organizationId: '222', - createdAt: '2023-02-24T09:40:19.708Z', - selfRegister: true, - createdBy: '40029484', - domain: 'developer', - name: '4group1', - updatedAt: '2023-02-24T09:41:06.140Z', - status: 'active', - }, - { - id: '304b042f-19f1-4d06-9788-104572eca795', - ssoId: '', - updatedBy: '1', - description: 'Test Group', - privateGroup: false, - oldId: '1', - organizationId: '222', - createdAt: '2017-05-18T10:29:38.000Z', - selfRegister: false, - createdBy: '1', - domain: '', - name: 'TestGroup', - status: 'active', - updatedAt: '2017-05-18T10:29:38.000Z', - }, - { - id: 'd55cc318-b1f4-4fb9-b3fa-991f0a237baf', - ssoId: '', - updatedBy: '00000000', - description: 'desc1-updated-4', - privateGroup: false, - oldId: '1', - organizationId: '111', - createdAt: '2022-06-24T05:57:26.298Z', - selfRegister: false, - createdBy: '00000000', - domain: '', - name: 'group1-updated-4', - updatedAt: '2022-06-24T05:57:32.958Z', - status: 'active', - }, - { - id: '042a9dac-19a4-464a-8e8b-c1b1fd18e55b', - ssoId: '', - updatedBy: '40029484', - description: 'desc1-updated-1', - privateGroup: false, - oldId: '1', - organizationId: '222', - createdAt: '2022-06-24T05:56:51.613Z', - selfRegister: false, - createdBy: '40029484', - domain: 'add_domain', - name: 'group1-updated-1', - updatedAt: '2022-06-24T05:57:08.406Z', - status: 'active', - }, - { - id: '110692e5-3bc9-47d5-b7c8-70fefaba0662', - ssoId: '', - updatedBy: '00000000', - description: 'desc1-updated-2', - privateGroup: false, - oldId: '1', - organizationId: '111', - createdAt: '2022-06-24T05:57:26.374Z', - selfRegister: false, - createdBy: '00000000', - domain: '', - name: 'group1-updated-2', - updatedAt: '2022-06-24T05:58:19.701Z', - status: 'active', - }, - { - id: 'e2e2a0ee-f02e-4756-8cd9-7c25f83d5f5b', - ssoId: '', - updatedBy: '305384', - description: 'Test Group 12345715P', - privateGroup: false, - oldId: '1234', - organizationId: '111', - createdAt: '2023-02-23T19:03:11.575Z', - selfRegister: true, - createdBy: '305384', - domain: '', - name: 'Test Group 12345717P', - updatedAt: '2023-02-23T19:59:31.018Z', - status: 'active', - }, - { - id: 'beb4cb39-6e06-45ec-b1da-b02d3529f2a7', - ssoId: '', - updatedBy: '305384', - description: 'Test Cached Group', - privateGroup: false, - oldId: '12345', - organizationId: '111', - createdAt: '2023-02-27T18:05:15.938Z', - selfRegister: true, - createdBy: '305384', - domain: '', - name: 'Test Cached Group', - updatedAt: '2023-02-27T18:06:20.116Z', - status: 'active', - }, - { - id: '11111111-2222-3333-9999-444444444444', - ssoId: '', - updatedBy: '305384', - description: 'Test Deleted Group', - privateGroup: false, - oldId: '12345', - organizationId: '222', - createdAt: '2023-02-27T18:05:15.938Z', - selfRegister: true, - createdBy: '305384', - domain: '', - name: 'Test Deleted Group', - updatedAt: '2023-02-27T18:06:20.116Z', - status: 'active', - }, -]; - -const userData: Prisma.UserCreateInput[] = [ - { - id: '8uHVTW2WHp8BbBPX7J0YTAwgYbYTfjsM', - universalUID: '8uHVTW2WHp8BbBPX7J0YTAwgYbYTfjsM', - createdBy: 'admin', - }, - { - id: '999f3f28-1549-4d35-ba40-b1b3512142dd', - universalUID: '10000021', - createdBy: 'admin', - }, - { - id: '999f3f28-1549-4d35-ba40-b1b3512142de', - universalUID: '10000022', - createdBy: 'admin', - }, - { - id: '999f3f28-1549-4d35-ba40-b1b351214333', - universalUID: '22838965', - createdBy: 'admin', - }, -]; +const ACTIVE_COMMUNITY_IDS = [ + 'wipro', + 'veterans', + 'blockchain', + 'cognitive', + 'qa', + 'mobile', + 'iot', + 'community-2', + 'cs', + 'demo-expert', + 'srmx', + 'taskforce', + 'tc-prod-dev', +] as const; + +interface CommunityMetadataSeedSource { + communityId: string; + communityName: string; + description?: string; + image?: string; + subdomains?: string[]; + groupIds?: string[]; + authorizedGroupIds?: string[]; + terms?: number[]; + hidden?: boolean; + hideSearch?: boolean; + hideFilter?: boolean; + chevronOverAvatar?: boolean; + footerText?: string; + leaderboardApiUrl?: string; + newsFeed?: string; + challengeFilter?: Record; + challengeListing?: Record; + metadata?: Record; + menuItems?: Record[]; + logos?: Record[]; + additionalLogos?: string[]; + accessDeniedPage?: Record; +} -const groupMembershipData: Prisma.GroupMembershipCreateManyInput[] = [ - { - id: 'b29f3f28-1549-4d35-ba40-b1b3512142cc', - groupId: '2fd8ba9f-e229-40f8-9f3e-6a75aba7c8f1', - createdAt: '2020-11-24T10:36:19.598Z', - createdBy: '40159127', - memberId: '40159127', - membershipType: 'user', - }, - { - id: 'aabd863f-52a1-49c2-8b68-2d58f77405b5', - groupId: '2fd8ba9f-e229-40f8-9f3e-6a75aba7c8f1', - createdAt: '2020-02-08T18:59:40.240Z', - createdBy: '00000000', - memberId: '23274118', - membershipType: 'user', - }, - { - id: '189e8a5c-28c5-423e-834f-e8f1e2ff85fc', - groupId: '2fd8ba9f-e229-40f8-9f3e-6a75aba7c8f1', - createdAt: '2020-02-08T18:59:40.106Z', - createdBy: '00000001', - memberId: '22838965', - membershipType: 'user', - }, - { - id: '999f3f28-1549-4d35-ba40-b1b3512142df', - groupId: '2fd8ba9f-e229-40f8-9f3e-6a75aba7c8f1', - createdAt: '2020-02-08T18:59:40.106Z', - createdBy: '00000001', - memberId: '8uHVTW2WHp8BbBPX7J0YTAwgYbYTfjsM', - membershipType: 'user', - roles: [ - { - role: 'groupAdmin', - createdAt: '2020-02-09T18:59:40.106Z', - createdBy: '20000002', - }, - { - role: 'groupManager', - createdAt: '2020-02-19T18:59:40.106Z', - createdBy: '20000003', - }, - ], - }, - { - id: '999f3f28-1549-4d35-ba40-b1b3512142a1', - groupId: 'd55cc318-b1f4-4fb9-b3fa-991f0a237baf', - createdAt: '2020-02-08T18:59:40.106Z', - createdBy: '00000001', - memberId: '22838965', - membershipType: 'user', - roles: [ - { - role: 'groupManager', - createdAt: '2020-02-09T18:59:40.106Z', - createdBy: '20000002', - }, - ], - }, - { - id: '999f3f28-1549-4d35-ba40-b1b3512142a2', - groupId: 'd55cc318-b1f4-4fb9-b3fa-991f0a237baf', - createdAt: '2020-02-08T18:59:40.106Z', - createdBy: '00000001', - memberId: '22838966', - membershipType: 'user', - }, - { - id: '999f3f28-1549-4d35-ba40-b1b3512142a3', - groupId: 'd55cc318-b1f4-4fb9-b3fa-991f0a237baf', - createdAt: '2020-02-08T18:59:40.106Z', - createdBy: '00000001', - memberId: '999f3f28-1549-4d35-ba40-b1b351214333', - membershipType: 'user', - }, - { - id: '999f3f28-1549-4d35-ba40-b1b3512142a4', - groupId: '546bb184-1338-4979-b4a4-f0e82e2602a8', - createdAt: '2020-02-08T18:59:40.106Z', - createdBy: '00000001', - memberId: '8uHVTW2WHp8BbBPX7J0YTAwgYbYTfjsM', - membershipType: 'user', - roles: [ - { - role: 'groupManager', - createdAt: '2020-02-09T18:59:40.106Z', - createdBy: '20000002', - }, - ], - }, - { - id: '999f3f28-1549-4d35-ba40-b1b3512142a5', - groupId: 'fabcb683-d749-47da-81f4-91f5f80a8571', - createdAt: '2020-02-08T18:59:40.106Z', - createdBy: '00000001', - memberId: '8uHVTW2WHp8BbBPX7J0YTAwgYbYTfjsM', - membershipType: 'user', - roles: [ - { - role: 'groupManager', - createdAt: '2020-02-09T18:59:40.106Z', - createdBy: '20000002', - }, - ], - }, -]; +/** + * Reads one community metadata JSON file from the community-app source folder. + * @param communityId Community identifier used as directory name. + * @returns Parsed metadata payload for the community. + */ +async function loadCommunityMetadata( + communityId: string, +): Promise { + const metadataPath = resolve( + __dirname, + '../../community-app/src/server/tc-communities', + communityId, + 'metadata.json', + ); + const metadataContent = await readFile(metadataPath, 'utf8'); + return JSON.parse(metadataContent) as CommunityMetadataSeedSource; +} -async function clearDB() { - await prisma.groupMembership.deleteMany(); - await prisma.user.deleteMany(); - await prisma.group.deleteMany(); +/** + * Maps raw metadata JSON into Prisma upsert data shape. + * @param metadata Raw metadata loaded from metadata.json. + * @returns Normalized data for create/update. + */ +function toCommunityUpsertData(metadata: CommunityMetadataSeedSource) { + const data = { + communityId: metadata.communityId, + communityName: metadata.communityName, + description: metadata.description ?? null, + image: metadata.image ?? null, + subdomains: metadata.subdomains ?? [], + groupIds: metadata.groupIds ?? [], + authorizedGroupIds: metadata.authorizedGroupIds ?? [], + terms: metadata.terms ?? [], + hidden: metadata.hidden ?? false, + hideSearch: metadata.hideSearch ?? false, + hideFilter: metadata.hideFilter ?? false, + chevronOverAvatar: metadata.chevronOverAvatar ?? false, + footerText: metadata.footerText ?? null, + leaderboardApiUrl: metadata.leaderboardApiUrl ?? null, + newsFeed: metadata.newsFeed ?? null, + challengeFilter: + metadata.challengeFilter === undefined + ? undefined + : (metadata.challengeFilter as Prisma.InputJsonValue), + challengeListing: + metadata.challengeListing === undefined + ? undefined + : (metadata.challengeListing as Prisma.InputJsonValue), + metadata: + metadata.metadata === undefined + ? undefined + : (metadata.metadata as Prisma.InputJsonValue), + menuItems: (metadata.menuItems ?? []) as Prisma.InputJsonValue, + logos: (metadata.logos ?? []) as Prisma.InputJsonValue, + additionalLogos: metadata.additionalLogos ?? [], + accessDeniedPage: + metadata.accessDeniedPage === undefined + ? undefined + : (metadata.accessDeniedPage as Prisma.InputJsonValue), + }; + + return data; } +/** + * Seeds community metadata records for active communities. + * @returns A promise that resolves when all upserts complete. + */ async function main() { - console.log(`Clear DB data ...`); - - await clearDB(); - - console.log(`Start seeding ...`); - - const groupObjs = await prisma.group.createManyAndReturn({ - data: groupData, - }); - console.log(`Created group data `); - - await prisma.group.update({ - where: { - id: groupObjs[0].id, - }, - data: { - subGroups: { - connect: [ - { id: groupObjs[1].id }, - { id: groupObjs[2].id }, - { id: groupObjs[3].id }, - ], - }, - }, - }); - - groupMembershipData.push({ - groupId: groupObjs[0].id, - memberId: groupObjs[1].id, - membershipType: 'group', - createdBy: 'test', - }); - - groupMembershipData.push({ - groupId: groupObjs[0].id, - memberId: groupObjs[2].id, - membershipType: 'group', - createdBy: 'test', - }); - - groupMembershipData.push({ - groupId: groupObjs[0].id, - memberId: groupObjs[3].id, - membershipType: 'group', - createdBy: 'test', - }); - - await prisma.group.update({ - where: { - id: groupObjs[1].id, - }, - data: { - subGroups: { - connect: [{ id: groupObjs[4].id }, { id: groupObjs[5].id }], - }, - }, - }); - - groupMembershipData.push({ - groupId: groupObjs[1].id, - memberId: groupObjs[4].id, - membershipType: 'group', - createdBy: 'test', - }); + for (const communityId of ACTIVE_COMMUNITY_IDS) { + const metadata = await loadCommunityMetadata(communityId); + const data = toCommunityUpsertData(metadata); - groupMembershipData.push({ - groupId: groupObjs[1].id, - memberId: groupObjs[5].id, - membershipType: 'group', - createdBy: 'test', - }); - - await prisma.group.update({ - where: { - id: groupObjs[2].id, - }, - data: { - subGroups: { - connect: [{ id: groupObjs[4].id }, { id: groupObjs[6].id }], - }, - }, - }); - - groupMembershipData.push({ - groupId: groupObjs[2].id, - memberId: groupObjs[4].id, - membershipType: 'group', - createdBy: 'test', - }); - - groupMembershipData.push({ - groupId: groupObjs[2].id, - memberId: groupObjs[5].id, - membershipType: 'group', - createdBy: 'test', - }); - - await prisma.group.update({ - where: { - id: groupObjs[4].id, - }, - data: { - subGroups: { - connect: [{ id: groupObjs[7].id }], - }, - }, - }); - - groupMembershipData.push({ - groupId: groupObjs[4].id, - memberId: groupObjs[7].id, - membershipType: 'group', - createdBy: 'test', - }); - - await prisma.group.update({ - where: { - id: groupObjs[7].id, - }, - data: { - subGroups: { - connect: [{ id: groupObjs[8].id }, { id: groupObjs[5].id }], + await prisma.communityMetadata.upsert({ + where: { + communityId: data.communityId, }, - }, - }); - - groupMembershipData.push({ - groupId: groupObjs[7].id, - memberId: groupObjs[8].id, - membershipType: 'group', - createdBy: 'test', - }); - - groupMembershipData.push({ - groupId: groupObjs[7].id, - memberId: groupObjs[5].id, - membershipType: 'group', - createdBy: 'test', - }); - - console.log(`Created group relationship `); - - await prisma.user.createManyAndReturn({ - data: userData, - }); - console.log(`Created user data `); - - await prisma.groupMembership.createManyAndReturn({ - data: groupMembershipData, - }); - console.log(`Created group membership data `); - - console.log(`Seeding finished.`); + create: data, + update: data, + }); + } } main() - .then(async () => { - await prisma.$disconnect(); + .catch((error) => { + console.error(error); + process.exitCode = 1; }) - .catch(async (e) => { - console.error(e); + .finally(async () => { await prisma.$disconnect(); - process.exit(1); }); diff --git a/src/api/api.module.ts b/src/api/api.module.ts index 49b3138..320221a 100644 --- a/src/api/api.module.ts +++ b/src/api/api.module.ts @@ -6,6 +6,7 @@ import { GroupController } from './group/group.controller'; import { GroupMembershipController } from './group-membership/groupMembership.controller'; import { GroupRoleController } from './group-role/groupRole.controller'; import { SubGroupController } from './subgroup/subGroup.controller'; +import { CommunityController } from './community/community.controller'; // import { AppealController } from './appeal/appeal.controller'; // import { ContactRequestsController } from './contact/contactRequests.controller'; // import { ReviewController } from './review/review.controller'; @@ -16,6 +17,7 @@ import { GroupService } from './group/group.service'; import { GroupMembershipService } from './group-membership/groupMembership.service'; import { GroupRoleService } from './group-role/groupRole.service'; import { SubGroupService } from './subgroup/subGroup.service'; +import { CommunityService } from './community/community.service'; // import { ReviewOpportunityService } from './review-opportunity/reviewOpportunity.service'; // import { ReviewApplicationService } from './review-application/reviewApplication.service'; // import { ReviewHistoryController } from './review-history/reviewHistory.controller'; @@ -29,6 +31,7 @@ import { SubGroupService } from './subgroup/subGroup.service'; GroupMembershipController, GroupRoleController, SubGroupController, + CommunityController, // AppealController, // ContactRequestsController, // ReviewController, @@ -42,6 +45,7 @@ import { SubGroupService } from './subgroup/subGroup.service'; GroupMembershipService, GroupRoleService, SubGroupService, + CommunityService, ], }) export class ApiModule {} diff --git a/src/api/community/community.controller.ts b/src/api/community/community.controller.ts new file mode 100644 index 0000000..2666817 --- /dev/null +++ b/src/api/community/community.controller.ts @@ -0,0 +1,133 @@ +import { Controller, Get, Param, Query, Req, Res } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { + CommunityCriteria, + CommunityListItemDto, + CommunityMetaResponseDto, +} from 'src/dto/community.dto'; +import { Scope } from 'src/shared/enums/scopes.enum'; +import { UserRole } from 'src/shared/enums/userRole.enum'; +import { setResHeader } from 'src/shared/helper'; +import { Scopes } from 'src/shared/decorators/scopes.decorator'; +import { Roles } from 'src/shared/guards/tokenRoles.guard'; +import { JwtUser, isAdmin } from 'src/shared/modules/global/jwt.service'; +import { CommunityService } from './community.service'; + +/** + * Exposes community metadata read endpoints. + */ +@ApiTags('Community') +@Controller('/communities') +export class CommunityController { + constructor(private readonly service: CommunityService) {} + + /** + * Lists communities based on pagination and optional filters. + * @param req HTTP request containing authenticated user context. + * @param res HTTP response used for pagination headers. + * @param criteria Query criteria for listing communities. + * @returns The list of matching communities. + */ + @ApiOperation({ + summary: 'List communities', + }) + @ApiResponse({ + status: 200, + description: 'Community list', + type: [CommunityListItemDto], + headers: { + 'X-Next-Page': { + description: 'The index of the next page', + schema: { type: 'integer' }, + }, + 'X-Page': { + description: 'The index of the current page (starting at 1)', + schema: { type: 'integer' }, + }, + 'X-Per-Page': { + description: 'The number of items to list per page', + schema: { type: 'integer' }, + }, + 'X-Total': { + description: 'The total number of items', + schema: { type: 'integer' }, + }, + 'X-Total-Pages': { + description: 'The total number of pages', + schema: { type: 'integer' }, + }, + Link: { + description: 'Pagination link header', + schema: { type: 'integer' }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Bad Request' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 500, description: 'Internal Error' }) + @Get() + @ApiBearerAuth() + @Roles(UserRole.Admin, UserRole.User) + @Scopes(Scope.ReadGroups, Scope.WriteGroups, Scope.AllGroups) + async list( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + @Query() criteria: CommunityCriteria, + ): Promise { + const authUser: JwtUser = req['user'] as JwtUser; + const tokenGroupIds = authUser.groupIds ?? []; + const callerGroupIds = + tokenGroupIds.length > 0 + ? tokenGroupIds + : await this.service.getMemberGroupIds(authUser.userId); + + const result = await this.service.listCommunities( + criteria, + isAdmin(authUser), + callerGroupIds, + ); + + setResHeader(req, res, result.page, result.perPage, result.total); + + return result.data; + } + + /** + * Retrieves complete metadata for one community. + * @param communityId The community identifier. + * @returns The community metadata payload. + */ + @ApiOperation({ + summary: 'Get community metadata', + }) + @ApiParam({ + name: 'communityId', + description: 'Community identifier', + }) + @ApiResponse({ + status: 200, + description: 'Community metadata', + type: CommunityMetaResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 404, description: 'Not Found' }) + @ApiResponse({ status: 500, description: 'Internal Error' }) + @Get('/:communityId/meta') + @ApiBearerAuth() + @Roles(UserRole.Admin, UserRole.User) + @Scopes(Scope.ReadGroups, Scope.WriteGroups, Scope.AllGroups) + async getMeta( + @Param('communityId') communityId: string, + ): Promise { + return this.service.getCommunityMeta(communityId); + } +} diff --git a/src/api/community/community.service.ts b/src/api/community/community.service.ts new file mode 100644 index 0000000..ac3e0b2 --- /dev/null +++ b/src/api/community/community.service.ts @@ -0,0 +1,150 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { + CommunityCriteria, + CommunityListItemDto, + CommunityMetaResponseDto, +} from 'src/dto/community.dto'; +import { PaginatedResponse } from 'src/dto/pagination.dto'; +import { PrismaService } from 'src/shared/modules/global/prisma.service'; + +/** + * Handles retrieval of community metadata records from the database. + */ +@Injectable() +export class CommunityService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Lists communities with pagination and optional filtering. + * @param criteria Query criteria with pagination, hidden inclusion, and subdomain filter. + * @param isAdmin Whether the caller has admin privileges. + * @param callerGroupIds Group identifiers associated with the caller. + * @returns A paginated response containing community list items. + */ + async listCommunities( + criteria: CommunityCriteria, + isAdmin: boolean, + callerGroupIds: string[], + ): Promise> { + const where: Prisma.CommunityMetadataWhereInput = {}; + const uniqueCallerGroupIds = Array.from(new Set(callerGroupIds)); + + if (!(isAdmin && criteria.includeHidden)) { + where.hidden = false; + } + + if (criteria.subdomain) { + where.subdomains = { has: criteria.subdomain }; + } + + where.OR = [ + { + authorizedGroupIds: { + isEmpty: true, + }, + }, + ...(uniqueCallerGroupIds.length > 0 + ? [ + { + authorizedGroupIds: { + hasSome: uniqueCallerGroupIds, + }, + }, + ] + : []), + ]; + + const total = await this.prisma.communityMetadata.count({ where }); + const take = criteria.perPage; + const skip = take * (criteria.page - 1); + + const data = await this.prisma.communityMetadata.findMany({ + where, + take, + skip, + orderBy: { + communityId: 'asc', + }, + select: { + communityId: true, + communityName: true, + description: true, + image: true, + subdomains: true, + groupIds: true, + authorizedGroupIds: true, + hidden: true, + menuItems: true, + logos: true, + footerText: true, + }, + }); + + const normalizedData: CommunityListItemDto[] = data.map((item) => ({ + ...item, + menuItems: item.menuItems as unknown as CommunityListItemDto['menuItems'], + logos: item.logos as unknown as CommunityListItemDto['logos'], + })); + + return { + data: normalizedData, + page: criteria.page, + perPage: criteria.perPage, + total, + }; + } + + /** + * Finds group memberships for a caller when token claims do not provide group IDs. + * @param memberId The caller member identifier. + * @returns Group identifiers that include the caller as a member. + */ + async getMemberGroupIds(memberId?: string): Promise { + if (!memberId) { + return []; + } + + const memberships = await this.prisma.groupMembership.findMany({ + where: { memberId }, + distinct: ['groupId'], + select: { groupId: true }, + }); + + return memberships.map((membership) => membership.groupId); + } + + /** + * Retrieves the full metadata record for a community. + * @param communityId The community identifier. + * @returns The full community metadata payload. + * @throws NotFoundException When no community metadata exists for the given community identifier. + */ + async getCommunityMeta( + communityId: string, + ): Promise { + const metadata = await this.prisma.communityMetadata.findUnique({ + where: { communityId }, + }); + + if (!metadata) { + throw new NotFoundException( + `Community metadata not found for communityId: ${communityId}`, + ); + } + + return { + ...metadata, + challengeFilter: + metadata.challengeFilter as CommunityMetaResponseDto['challengeFilter'], + challengeListing: + metadata.challengeListing as CommunityMetaResponseDto['challengeListing'], + metadata: metadata.metadata as CommunityMetaResponseDto['metadata'], + menuItems: + metadata.menuItems as unknown as CommunityMetaResponseDto['menuItems'], + logos: metadata.logos as unknown as CommunityMetaResponseDto['logos'], + accessDeniedPage: + metadata.accessDeniedPage as CommunityMetaResponseDto['accessDeniedPage'], + }; + } +} diff --git a/src/dto/community.dto.ts b/src/dto/community.dto.ts new file mode 100644 index 0000000..3ca78c8 --- /dev/null +++ b/src/dto/community.dto.ts @@ -0,0 +1,352 @@ +import { Transform } from 'class-transformer'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PaginationDto } from './pagination.dto'; +import { transformBoolean } from 'src/shared/helper'; + +/** + * Represents one menu item displayed in community navigation. + */ +export class CommunityMenuItemDto { + @ApiProperty({ + description: 'Menu item title', + type: 'string', + }) + title: string; + + @ApiProperty({ + description: 'Target URL for the menu item', + type: 'string', + }) + url: string; + + @ApiPropertyOptional({ + description: 'Whether the link opens in a new browser tab', + type: 'boolean', + }) + openNewTab?: boolean; +} + +/** + * Represents a logo entry with image and target URL. + */ +export class CommunityLogoDto { + @ApiProperty({ + description: 'Logo image path', + type: 'string', + }) + img: string; + + @ApiProperty({ + description: 'Logo link URL', + type: 'string', + }) + url: string; +} + +/** + * Represents optional access-denied content settings for a community. + */ +export class CommunityAccessDeniedPageDto { + @ApiProperty({ + description: 'Contentful viewport identifier', + type: 'string', + }) + viewportId: string; + + @ApiPropertyOptional({ + description: 'Optional content space name', + type: 'string', + }) + spaceName?: string; +} + +/** + * Represents challenge filter JSON stored for a community. + */ +export class CommunityChallengeFilterDto { + @ApiPropertyOptional({ + description: 'Allowed group identifiers', + type: [String], + }) + groupIds?: string[]; + + @ApiPropertyOptional({ + description: 'Optional OR filter clauses', + type: [Object], + }) + or?: Record[]; + + @ApiPropertyOptional({ + description: 'Optional tag filters', + type: [String], + }) + tags?: string[]; +} + +/** + * Represents challenge listing behavior settings for a community. + */ +export class CommunityChallengeListingDto { + @ApiPropertyOptional({ + description: 'Whether to skip community filtering by default', + type: 'boolean', + }) + ignoreCommunityFilterByDefault?: boolean; + + @ApiPropertyOptional({ + description: 'Whether challenge links open in new tabs', + type: 'boolean', + }) + openChallengesInNewTabs?: boolean; +} + +/** + * Full response shape for a community metadata record. + */ +export class CommunityMetaResponseDto { + @ApiProperty({ + description: 'Internal metadata identifier', + type: 'string', + }) + id: string; + + @ApiProperty({ + description: 'Community identifier', + type: 'string', + }) + communityId: string; + + @ApiProperty({ + description: 'Community display name', + type: 'string', + }) + communityName: string; + + @ApiPropertyOptional({ + description: 'Community description', + type: 'string', + }) + description?: string | null; + + @ApiPropertyOptional({ + description: 'Community image filename', + type: 'string', + }) + image?: string | null; + + @ApiProperty({ + description: 'Community subdomains', + type: [String], + }) + subdomains: string[]; + + @ApiProperty({ + description: 'Community group IDs', + type: [String], + }) + groupIds: string[]; + + @ApiProperty({ + description: 'Authorized group IDs for gated access', + type: [String], + }) + authorizedGroupIds: string[]; + + @ApiProperty({ + description: 'Applicable terms IDs', + type: [Number], + }) + terms: number[]; + + @ApiProperty({ + description: 'Flag indicating hidden community', + type: 'boolean', + }) + hidden: boolean; + + @ApiProperty({ + description: 'Flag indicating whether search is hidden', + type: 'boolean', + }) + hideSearch: boolean; + + @ApiProperty({ + description: 'Flag indicating whether filters are hidden', + type: 'boolean', + }) + hideFilter: boolean; + + @ApiProperty({ + description: 'Flag indicating chevron overlay on avatar', + type: 'boolean', + }) + chevronOverAvatar: boolean; + + @ApiPropertyOptional({ + description: 'Community footer text', + type: 'string', + }) + footerText?: string | null; + + @ApiPropertyOptional({ + description: 'Leaderboard API URL', + type: 'string', + }) + leaderboardApiUrl?: string | null; + + @ApiPropertyOptional({ + description: 'News feed URL', + type: 'string', + }) + newsFeed?: string | null; + + @ApiPropertyOptional({ + description: 'Challenge filter settings', + type: CommunityChallengeFilterDto, + }) + challengeFilter?: CommunityChallengeFilterDto | null; + + @ApiPropertyOptional({ + description: 'Challenge listing settings', + type: CommunityChallengeListingDto, + }) + challengeListing?: CommunityChallengeListingDto | null; + + @ApiPropertyOptional({ + description: 'Additional community metadata JSON payload', + type: Object, + }) + metadata?: Record | null; + + @ApiProperty({ + description: 'Menu entries for the community', + type: [CommunityMenuItemDto], + }) + menuItems: CommunityMenuItemDto[]; + + @ApiProperty({ + description: 'Community logos', + type: [CommunityLogoDto], + }) + logos: CommunityLogoDto[]; + + @ApiProperty({ + description: 'Additional logo image paths', + type: [String], + }) + additionalLogos: string[]; + + @ApiPropertyOptional({ + description: 'Access denied page content settings', + type: CommunityAccessDeniedPageDto, + }) + accessDeniedPage?: CommunityAccessDeniedPageDto | null; + + @ApiProperty({ + description: 'Record creation timestamp', + type: Date, + }) + createdAt: Date; + + @ApiProperty({ + description: 'Record update timestamp', + type: Date, + }) + updatedAt: Date; +} + +/** + * Slim projection for community listing. + */ +export class CommunityListItemDto { + @ApiProperty({ + description: 'Community identifier', + type: 'string', + }) + communityId: string; + + @ApiProperty({ + description: 'Community display name', + type: 'string', + }) + communityName: string; + + @ApiPropertyOptional({ + description: 'Community description', + type: 'string', + }) + description?: string | null; + + @ApiPropertyOptional({ + description: 'Community image filename', + type: 'string', + }) + image?: string | null; + + @ApiProperty({ + description: 'Community subdomains', + type: [String], + }) + subdomains: string[]; + + @ApiProperty({ + description: 'Community group IDs', + type: [String], + }) + groupIds: string[]; + + @ApiProperty({ + description: 'Authorized group IDs for gated access', + type: [String], + }) + authorizedGroupIds: string[]; + + @ApiProperty({ + description: 'Flag indicating hidden community', + type: 'boolean', + }) + hidden: boolean; + + @ApiProperty({ + description: 'Menu entries for the community', + type: [CommunityMenuItemDto], + }) + menuItems: CommunityMenuItemDto[]; + + @ApiProperty({ + description: 'Community logos', + type: [CommunityLogoDto], + }) + logos: CommunityLogoDto[]; + + @ApiPropertyOptional({ + description: 'Community footer text', + type: 'string', + }) + footerText?: string | null; +} + +/** + * Query criteria for listing communities. + */ +export class CommunityCriteria extends PaginationDto { + @ApiPropertyOptional({ + description: 'Include hidden communities in the results', + default: false, + type: 'boolean', + }) + @Transform(({ value }) => transformBoolean(value)) + @IsBoolean() + @IsOptional() + includeHidden: boolean = false; + + @ApiPropertyOptional({ + description: 'Filter by subdomain', + type: 'string', + }) + @IsString() + @IsNotEmpty() + @IsOptional() + subdomain?: string; +} diff --git a/src/shared/modules/global/jwt.service.ts b/src/shared/modules/global/jwt.service.ts index 60d04b7..d38291e 100644 --- a/src/shared/modules/global/jwt.service.ts +++ b/src/shared/modules/global/jwt.service.ts @@ -12,6 +12,7 @@ import { AuthConfig } from '../../config/auth.config'; export interface JwtUser { userId?: string; handle?: string; + groupIds?: string[]; roles?: UserRole[]; scopes?: string[]; isMachine: boolean; @@ -139,6 +140,14 @@ export class JwtService implements OnModuleInit { } } } + + const groupIds = this.extractGroupIds( + decodedToken as Record, + ); + if (groupIds.length > 0) { + user.groupIds = groupIds; + } + return user; } catch (error) { console.error('Token validation failed:', error); @@ -213,4 +222,39 @@ export class JwtService implements OnModuleInit { return Array.from(expandedScopes); } + + /** + * Extracts caller group identifiers from known token claim keys. + * @param decodedToken The decoded JWT payload. + * @returns A deduplicated list of string group identifiers. + */ + private extractGroupIds(decodedToken: Record): string[] { + const groupIds = new Set(); + + for (const key of Object.keys(decodedToken)) { + const lowerKey = key.toLowerCase(); + const isGroupClaim = + lowerKey === 'groups' || + lowerKey.endsWith('/groups') || + lowerKey.endsWith('groups') || + lowerKey.endsWith('groupids'); + + if (!isGroupClaim) { + continue; + } + + const claimValue = decodedToken[key]; + if (!Array.isArray(claimValue)) { + continue; + } + + for (const value of claimValue) { + if (typeof value === 'string' && value.trim().length > 0) { + groupIds.add(value); + } + } + } + + return Array.from(groupIds); + } }