diff --git a/.env.example b/.env.example index 3595585e7..8b91db833 100644 --- a/.env.example +++ b/.env.example @@ -66,3 +66,6 @@ INTERACTION_FOLLOWUP_DELAY_IN_DAYS= // no of milliseconds delay in between interacitons INTERACTION_INTERVAL_DELAY_IN_MILLISECONDS= + +// no of days to decide recent record +RECENT_RECORD_DURATION_IN_DAYS= \ No newline at end of file diff --git a/.forestadmin-schema.json b/.forestadmin-schema.json index be5d6b157..89f9e2fa4 100644 --- a/.forestadmin-schema.json +++ b/.forestadmin-schema.json @@ -173,6 +173,22 @@ "type": "Date", "validations": [] }, + { + "defaultValue": null, + "enums": null, + "field": "eventName", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, { "defaultValue": null, "enums": null, @@ -205,6 +221,22 @@ "type": "Boolean", "validations": [] }, + { + "defaultValue": null, + "enums": null, + "field": "projectName", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, { "defaultValue": null, "enums": null, @@ -255,6 +287,22 @@ {"type": "is present", "message": "Failed validation rule: 'Present'"} ] }, + { + "defaultValue": null, + "enums": null, + "field": "teamName", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, { "defaultValue": null, "enums": null, @@ -3505,6 +3553,23 @@ "type": ["String"], "validations": [] }, + { + "defaultValue": null, + "enums": null, + "field": "PLEventLocation", + "integration": null, + "inverseOf": "PLEvents", + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "PLEventLocation.uid", + "relationship": "BelongsTo", + "type": "String", + "validations": [] + }, { "defaultValue": null, "enums": null, @@ -3619,24 +3684,6 @@ "type": "Boolean", "validations": [] }, - { - "defaultValue": null, - "enums": null, - "field": "location", - "integration": null, - "inverseOf": null, - "isFilterable": true, - "isPrimaryKey": false, - "isReadOnly": false, - "isRequired": true, - "isSortable": true, - "isVirtual": false, - "reference": null, - "type": "String", - "validations": [ - {"type": "is present", "message": "Failed validation rule: 'Present'"} - ] - }, { "defaultValue": null, "enums": null, @@ -3924,6 +3971,22 @@ "type": "Date", "validations": [] }, + { + "defaultValue": null, + "enums": null, + "field": "grouping_id", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, { "defaultValue": null, "enums": null, @@ -3940,6 +4003,42 @@ "type": "Number", "validations": [] }, + { + "defaultValue": false, + "enums": null, + "field": "isHost", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Boolean", + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] + }, + { + "defaultValue": false, + "enums": null, + "field": "isSpeaker", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Boolean", + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] + }, { "defaultValue": null, "enums": null, @@ -4049,6 +4148,251 @@ "paginationType": "page", "segments": [] }, + { + "actions": [], + "fields": [ + { + "defaultValue": null, + "enums": null, + "field": "PLEvents", + "integration": null, + "inverseOf": "PLEventLocation", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": false, + "isVirtual": false, + "reference": "PLEvent.uid", + "relationship": "HasMany", + "type": ["String"], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "additionalInfo", + "integration": null, + "inverseOf": null, + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Json", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "createdAt", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Date", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "flag", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "icon", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "id", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": true, + "isReadOnly": true, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Number", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "latitude", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "location", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "longitude", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "priority", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Number", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "resources", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": ["Json"], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "timezone", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "uid", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "updatedAt", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Date", + "validations": [] + } + ], + "icon": null, + "integration": null, + "isReadOnly": false, + "isSearchable": true, + "isVirtual": false, + "name": "PLEventLocation", + "onlyForRelationships": false, + "paginationType": "page", + "segments": [] + }, { "actions": [], "fields": [ diff --git a/apps/web-api/prisma/migrations/20240920050707_irl_gathering_v2/migration.sql b/apps/web-api/prisma/migrations/20240920050707_irl_gathering_v2/migration.sql new file mode 100644 index 000000000..604288c33 --- /dev/null +++ b/apps/web-api/prisma/migrations/20240920050707_irl_gathering_v2/migration.sql @@ -0,0 +1,38 @@ +/* + Warnings: + + - You are about to drop the column `location` on the `PLEvent` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "PLEvent" DROP COLUMN "location", +ADD COLUMN "locationUid" TEXT; + +-- AlterTable +ALTER TABLE "PLEventGuest" ADD COLUMN "isHost" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isSpeaker" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "PLEventLocation" ( + "id" SERIAL NOT NULL, + "uid" TEXT NOT NULL, + "location" TEXT NOT NULL, + "timezone" TEXT NOT NULL, + "latitude" TEXT, + "longitude" TEXT, + "flag" TEXT, + "icon" TEXT, + "resources" JSONB[], + "additionalInfo" JSONB, + "priority" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PLEventLocation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PLEventLocation_uid_key" ON "PLEventLocation"("uid"); + +-- AddForeignKey +ALTER TABLE "PLEvent" ADD CONSTRAINT "PLEvent_locationUid_fkey" FOREIGN KEY ("locationUid") REFERENCES "PLEventLocation"("uid") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/web-api/prisma/schema.prisma b/apps/web-api/prisma/schema.prisma index 36412bb0d..da5efc27a 100644 --- a/apps/web-api/prisma/schema.prisma +++ b/apps/web-api/prisma/schema.prisma @@ -342,6 +342,23 @@ model Project { relatedQuestions DiscoveryQuestion[] @relation("ProjectRelatedDiscoveryQuestions") } +model PLEventLocation { + id Int @id @default(autoincrement()) + uid String @unique @default(cuid()) + location String + timezone String + latitude String? + longitude String? + flag String? + icon String? + resources Json[] + additionalInfo Json? + priority Int? + events PLEvent[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model PLEvent { id Int @id @default(autoincrement()) uid String @unique @default(cuid()) @@ -357,7 +374,6 @@ model PLEvent { shortDescription String? websiteURL String? isFeatured Boolean? @default(false) - location String slugURL String @unique resources Json[] priority Int? @@ -366,8 +382,10 @@ model PLEvent { endDate DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - eventGuests PLEventGuest[] relatedQuestions DiscoveryQuestion[] @relation("PLEventRelatedDiscoveryQuestions") + locationUid String? + location PLEventLocation? @relation(fields: [locationUid], references: [uid]) + eventGuests PLEventGuest[] } model PLEventGuest { @@ -386,6 +404,8 @@ model PLEventGuest { event PLEvent @relation(fields: [eventUid], references: [uid], onDelete: Cascade) additionalInfo Json? topics String[] + isHost Boolean @default(false) + isSpeaker Boolean @default(false) } model FocusArea { diff --git a/apps/web-api/src/members/members.controller.ts b/apps/web-api/src/members/members.controller.ts index 606f269ce..f28d5aa9f 100644 --- a/apps/web-api/src/members/members.controller.ts +++ b/apps/web-api/src/members/members.controller.ts @@ -48,6 +48,7 @@ export class MemberController { builtQuery.where, this.membersService.buildNameFilters(queryParams), this.membersService.buildRoleFilters(queryParams), + this.membersService.buildRecentMembersFilter(queryParams) ], }; return await this.membersService.findAll(builtQuery); @@ -64,7 +65,11 @@ export class MemberController { delete builtQuery.where?.name; } builtQuery.where = { - AND: [builtQuery.where, this.membersService.buildNameFilters(queryParams)], + AND: [ + builtQuery.where, + this.membersService.buildNameFilters(queryParams), + this.membersService.buildRecentMembersFilter(queryParams) + ], }; return await this.membersService.getRolesWithCount(builtQuery, queryParams); } @@ -98,6 +103,12 @@ export class MemberController { return await this.membersService.updatePreference(id, preference); } + @Api(server.route.updateMember) + @UseGuards(UserTokenValidation) + async updateMember(@Param('uid') uid, @Body() body) { + return await this.membersService.updateMember(uid, body); + } + @Api(server.route.getMemberPreferences) @UseGuards(AuthGuard) @NoCache() diff --git a/apps/web-api/src/members/members.service.ts b/apps/web-api/src/members/members.service.ts index 609004de9..70a695e82 100644 --- a/apps/web-api/src/members/members.service.ts +++ b/apps/web-api/src/members/members.service.ts @@ -148,9 +148,10 @@ export class MembersService { findOne( uid: string, - queryOptions: Omit = {} + queryOptions: Omit = {}, + tx?: Prisma.TransactionClient ) { - return this.prisma.member.findUniqueOrThrow({ + return (tx || this.prisma).member.findUniqueOrThrow({ where: { uid }, ...queryOptions, include: { @@ -550,6 +551,17 @@ export class MembersService { return response; } + async updateMember(uid, member) { + const response = this.prisma.member.update( + { + where: { uid }, + data: { ...member } + } + ); + this.cacheService.reset(); + return response; + } + async getPreferences(uid) { const resp:any = await this.prisma.member.findUnique( { @@ -601,6 +613,27 @@ export class MembersService { }); } + /** + * This method constructs a dynamic filter query for retrieving recent members + * created within a specified number of days, based on the 'recent' query parameter + * and an environment variable to configure the timeline. + * + * @param queryParams - HTTP request query parameters object + * @returns Constructed query with a 'createdAt' filter if 'recent' is set to 'true', + * or an empty object if 'recent' is not provided or set to 'false'. + */ + buildRecentMembersFilter(queryParams) { + const { isRecent } = queryParams; + if (isRecent === 'true') { + return { + createdAt: { + gte: new Date(Date.now() - (parseInt(process.env.RECENT_RECORD_DURATION_IN_DAYS || '30') * 24 * 60 * 60 * 1000)) + } + }; + } + return {}; + } + /** * This method construct the dynamic query to search either by roleTags or * by role name from the teamMemberRole table from query params @@ -668,9 +701,9 @@ export class MembersService { return { }; } - async updateTelegramIfChanged(member, telegram) { - if (telegram != '' && member.telegramHandler != telegram) { - member = await this.prisma.member.update({ + async updateTelegramIfChanged(member, telegram, tx?:Prisma.TransactionClient) { + if (member.telegramHandler != telegram) { + member = await (tx || this.prisma).member.update({ where: { uid: member.uid }, data: { telegramHandler: telegram @@ -680,9 +713,9 @@ export class MembersService { return member; } - async updateOfficeHoursIfChanged(member, officeHours) { - if (officeHours != '' && member.officeHours != officeHours) { - member = await this.prisma.member.update({ + async updateOfficeHoursIfChanged(member, officeHours, tx?:Prisma.TransactionClient) { + if (member.officeHours != officeHours) { + member = await (tx || this.prisma).member.update({ where: { uid: member.uid }, data: { officeHours diff --git a/apps/web-api/src/participants-request/participants-request.service.ts b/apps/web-api/src/participants-request/participants-request.service.ts index 6b2c850b9..295954cff 100644 --- a/apps/web-api/src/participants-request/participants-request.service.ts +++ b/apps/web-api/src/participants-request/participants-request.service.ts @@ -464,6 +464,7 @@ export class ParticipantsRequestService { dataToSave['moreDetails'] = dataToProcess.moreDetails; dataToSave['plnStartDate'] = dataToProcess.plnStartDate; dataToSave['openToWork'] = dataToProcess.openToWork; + dataToSave['bio'] = dataToProcess.bio; // Skills relation mapping dataToSave['skills'] = { diff --git a/apps/web-api/src/pl-events/pl-event-guests.service.ts b/apps/web-api/src/pl-events/pl-event-guests.service.ts new file mode 100644 index 000000000..27037741d --- /dev/null +++ b/apps/web-api/src/pl-events/pl-event-guests.service.ts @@ -0,0 +1,349 @@ +import { Injectable, NotFoundException, ConflictException, BadRequestException, Inject, CACHE_MANAGER } from '@nestjs/common'; +import { LogService } from '../shared/log.service'; +import { PrismaService } from '../shared/prisma.service'; +import { Prisma, Member } from '@prisma/client'; +import { MembersService } from '../members/members.service'; +import { Cache } from 'cache-manager'; +import { PLEventLocationsService } from './pl-event-locations.service'; +import { + CreatePLEventGuestSchemaDto, + UpdatePLEventGuestSchemaDto +} from 'libs/contracts/src/schema'; +import { + FormattedLocationWithEvents, + PLEvent +} from './pl-event-locations.types'; + +@Injectable() +export class PLEventGuestsService { + constructor( + private prisma: PrismaService, + private logger: LogService, + private memberService: MembersService, + private eventLocationsService: PLEventLocationsService, + @Inject(CACHE_MANAGER) private cacheService: Cache + ) {} + + /** + * This method creates multiple event guests for a specific location. + * @param data Data required for creating event guests, such as event and member details + * @param member The member object initiating the creation of event guests + * - Admins can create guests for other members, while non-admins create guests for themselves. + * @returns The result of creating multiple event guests + * - Resets the cache after creation. + */ + async createPLEventGuestByLocation( + data: CreatePLEventGuestSchemaDto, + member: Member, + tx?: Prisma.TransactionClient + ) { + try { + const isAdmin = this.memberService.checkIfAdminUser(member); + await this.updateMemberDetails(data, member, isAdmin, tx); + data.memberUid = isAdmin ? data.memberUid : member.uid; + const guests = this.formatInputToEventGuests(data); + const result = await (tx || this.prisma).pLEventGuest.createMany({ data: guests }); + this.cacheService.reset(); + return result; + } catch(err) { + this.handleErrors(err); + } + }; + + /** + * This method modifies event guests for upcoming events by first deleting existing guests and then creating new ones. + * @param data Data required for modifying event guests, such as event and member details + * @param location The location object containing the upcoming events + * @param member The member object initiating the modification + * @returns The result of modifying event guests for upcoming events + * - Deletes the existing guests and calls the `createPLEventGuestByLocation` method for new guests. + */ + async modifyPLEventGuestByLocation( + data: UpdatePLEventGuestSchemaDto, + location: FormattedLocationWithEvents, + member: Member, + type: string + ) { + try { + const events = type === "upcoming" ? location.upcomingEvents : location.pastEvents; + return await this.prisma.$transaction(async (tx) => { + const isAdmin = this.memberService.checkIfAdminUser(member); + await tx.pLEventGuest.deleteMany({ + where: { + memberUid: isAdmin ? data.memberUid : member.uid, + eventUid: { + in: events.map(event => event.uid) + } + } + }); + return await this.createPLEventGuestByLocation(data, member, tx); + }); + } catch (err) { + this.handleErrors(err); + } + } + + + /** + * This method deletes event guests for a specific location and given members. + * @param membersAndEvents An array of objects containing member and event UIDs + * @returns The result of deleting event guests + * - Delete Guests from events , then resets the cache. + */ + async deletePLEventGuests(membersAndEvents) { + try { + const deleteConditions = membersAndEvents.flatMap(({ memberUid, events }) => + events.map(eventUid => ({ memberUid, eventUid })) + ); + const result = await this.prisma.pLEventGuest.deleteMany({ + where: { + OR: deleteConditions + } + }); + await this.cacheService.reset(); + return result; + } catch (err) { + this.handleErrors(err); + } + }; + + /** + * This method retrieves event guests by location and type (upcoming or past events). + * @param locationUid The unique identifier of the event location + * @param type The type of events to filter by (either "upcoming" or "past") + * @param isUserLoggedIn Boolean indicating whether the user is logged in + * @returns An array of event guests, with sensitive details filtered based on login status + * - Applies member preferences on displaying details like telegramId and office hours. + */ + async getPLEventGuestsByLocationAndType( + locationUid: string, + type: string, + isUserLoggedIn: boolean + ) { + try { + let events; + if (type === "upcoming") { + events = await this.eventLocationsService.getUpcomingEventsByLocation(locationUid); + } else { + events = await this.eventLocationsService.getPastEventsByLocation(locationUid); + } + const result = await this.prisma.pLEventGuest.findMany({ + where: { + eventUid: { + in: events.map(event => event.uid) + } + }, + select: { + uid: true, + reason: true, + memberUid: true, + teamUid: true, + topics: true, + additionalInfo: true, + isHost: true, + isSpeaker: true, + event: { + select: { + slugURL: true, + uid: true, + name: true, + type: true, + description: true, + startDate: true, + endDate: true, + logo: { select: { url: true } }, + banner: { select: { url: true } }, + resources: true, + additionalInfo: true + } + }, + member: { + select: { + name: true, + image: { select: { url: true } }, + telegramHandler: isUserLoggedIn ? true : false, + preferences: true, + officeHours: isUserLoggedIn ? true : false, + teamMemberRoles: { + select:{ + team: { + select:{ + uid: true, + name: true, + logo: { select: { url: true } } + } + } + } + }, + projectContributions: { + select:{ + project:{ + select:{ + name: true, + isDeleted: true + } + } + } + }, + createdProjects:{ + select: { + name: true, + isDeleted: true + } + } + } + }, + team: { + select:{ + uid: true, + name: true, + logo: { select: { url: true } } + } + }, + createdAt: true, + telegramId: isUserLoggedIn ? true : false, + officeHours: isUserLoggedIn ? true : false + } + }); + this.restrictTelegramBasedOnMemberPreference(result, isUserLoggedIn); + // this.restrictOfficeHours(result, isUserLoggedIn); + return result; + } + catch(err) { + this.handleErrors(err); + } + }; + + /** + * This method updates the member details such as Telegram ID and office hours based on the provided guest data. + * @param guest The guest data object containing the updated details + * @param member The member object to be updated + * @param isAdmin Boolean indicating whether the current user is an admin + * - Admins can update other members' details, while non-admins can only update their own details. + */ + async updateMemberDetails( + guest: any, + member: Member, + isAdmin: boolean, + tx?: Prisma.TransactionClient + ) { + if (isAdmin) { + const guestMember = await this.memberService.findOne(guest.memberUid, {}, tx); + await this.memberService.updateTelegramIfChanged(guestMember, guest.telegramId, tx); + await this.memberService.updateOfficeHoursIfChanged(guestMember, guest.officeHours, tx); + } else { + await this.memberService.updateTelegramIfChanged(member, guest.telegramId, tx); + await this.memberService.updateOfficeHoursIfChanged(member, guest.officeHours, tx); + } + } + + /** + * This method checks whether all provided events are upcoming based on the list of upcoming events. + * @param upcomingEvents An array of upcoming events + * @param events An array of events to check + * @returns Boolean indicating whether all provided events are upcoming. + */ + checkIfEventsAreUpcoming(upcomingEvents: PLEvent[], events) { + return events.every((event) => { + return upcomingEvents.some((upcomingEvent) => { + return upcomingEvent.uid === event.uid; + }); + }); + }; + + /** + * This method formats the input data to create event guests with the required details for each event. + * @param input The input data containing details such as events, topics, telegram ID, office hours, etc. + * @returns An array of formatted event guest objects to be inserted into the database. + */ + private formatInputToEventGuests(input: CreatePLEventGuestSchemaDto) { + return input.events.map((event) => { + const additionalInfo = { + ...input.additionalInfo, + hostSubEvents: event.hostSubEvents || [], + speakerSubEvents: event.speakerSubEvents || [] + }; + return { + telegramId: input.telegramId || null, + officeHours: input.officeHours || null, + reason: input.reason || null, + memberUid: input.memberUid, + teamUid: input.teamUid, + eventUid: event.uid, + additionalInfo: additionalInfo, + topics: input.topics || [], + isHost: event.isHost || false, + isSpeaker: event.isSpeaker || false + }; + }); + }; + + /** + * This method restricts the visibility of Telegram IDs based on member preferences. + * @param eventGuests An array of event guests + * @param isUserLoggedIn Boolean indicating whether the user is logged in + * @returns The event guests array with Telegram details filtered based on preferences. + */ + restrictTelegramBasedOnMemberPreference(eventGuests, isUserLoggedIn: boolean) { + if (isUserLoggedIn && eventGuests) { + eventGuests = eventGuests.map((guest:any) => { + // if (!guest.telegramId) { + // delete guest.member.telegramHandler; + // return guest; + // } + if (!guest.member.preferences) { + return guest; + } + if (!guest.member.preferences.showTelegram) { + delete guest.member.telegramHandler; + delete guest.telegramId; + } + return guest; + }); + } + return eventGuests; + }; + + /** + * This method restricts the visibility of office hours based on login status. + * @param eventGuests An array of event guests + * @param isUserLoggedIn Boolean indicating whether the user is logged in + * @returns The event guests array with office hours filtered for non-logged-in users. + */ + restrictOfficeHours(eventGuests, isUserLoggedIn: boolean) { + if (eventGuests && isUserLoggedIn) { + eventGuests = eventGuests.map((guest:any) => { + if (!guest.officeHours) { + delete guest.member.officeHours; + } + return guest; + }); + } + return eventGuests; + }; + + /** + * This method handles various types of database errors, especially related to event guests. + * @param error The error object caught during operations + * @param message Optional additional message to include in the exception + * - Throws ConflictException for unique constraint violations, BadRequestException for validation errors, and NotFoundException when an event is not found. + */ + private handleErrors(error, message?) { + this.logger.error(error); + if (error instanceof Prisma.PrismaClientKnownRequestError) { + switch (error?.code) { + case 'P2002': + throw new ConflictException('Unique key constraint error on Event Guest:', error.message); + case 'P2003': + throw new BadRequestException('Foreign key constraint error on Event Guest', error.message); + case 'P2025': + throw new NotFoundException('Event is not found with uid:' + message); + default: + throw error; + } + } else if (error instanceof Prisma.PrismaClientValidationError) { + throw new BadRequestException('Database field validation error on Event Guest', error.message); + } + throw error; + }; +} diff --git a/apps/web-api/src/pl-events/pl-event-locations.service.ts b/apps/web-api/src/pl-events/pl-event-locations.service.ts new file mode 100644 index 000000000..a65f8921e --- /dev/null +++ b/apps/web-api/src/pl-events/pl-event-locations.service.ts @@ -0,0 +1,175 @@ +import moment from 'moment-timezone'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { LogService } from '../shared/log.service'; +import { PrismaService } from '../shared/prisma.service'; +import { Prisma } from '@prisma/client'; +import { + PLEventLocationWithEvents, + FormattedLocationWithEvents, + PLEvent +} from './pl-event-locations.types'; + +@Injectable() +export class PLEventLocationsService { + constructor( + private prisma: PrismaService, + private logger: LogService + ) {} + + /** + * This method retrieves the event location by its UID, including all associated events. + * @param uid The unique identifier for the event location + * @returns The event location object with associated events + * - The events include details such as name, type, description, startDate, endDate, and additional info. + * - Throws NotFoundException if the location with the given UID is not found. + */ + async getPLEventLocationByUid(uid: string): Promise { + try { + const location: PLEventLocationWithEvents = await this.prisma.pLEventLocation.findUniqueOrThrow({ + where: { uid }, + include: { + events: { + select: { + slugURL: true, + uid: true, + name: true, + type: true, + description: true, + startDate: true, + endDate: true, + logo: true, + banner: true, + resources: true, + additionalInfo: true + } + } + } + }); + return this.formatLocation(location); + } catch (error) { + return this.handleErrors(error, uid); + } + }; + + /** + * This method retrieves all upcoming events for a specified location. + * @param locationUid The unique identifier of the event location + * @returns An array of upcoming events for the given location + * - The events include details like name, description, and date, formatted in the location's timezone. + */ + async getUpcomingEventsByLocation(locationUid: string): Promise { + const result = await this.getPLEventLocationByUid(locationUid); + return result?.upcomingEvents; + } + + /** + * This method retrieves all past events for a specified location. + * @param locationUid The unique identifier of the event location + * @returns An array of past events for the given location + * - The events include details like name, description, and date, formatted in the location's timezone. + */ + async getPastEventsByLocation(locationUid: string): Promise { + const result = await this.getPLEventLocationByUid(locationUid); + return result?.pastEvents; + } + + /** + * This method retrieves a list of event locations based on the given query options. + * @param queryOptions Options for querying the event locations (e.g., filtering and sorting) + * @returns An array of event locations, each with associated events + * - Each event location includes event details such as name, startDate, and resources. + */ + async getPLEventLocations(queryOptions: Prisma.PLEventLocationFindManyArgs): Promise { + try { + const locations = await this.prisma.pLEventLocation.findMany({ + ...queryOptions, + include: { + events: { + select: { + slugURL: true, + uid: true, + name: true, + type: true, + description: true, + startDate: true, + endDate: true, + logo: true, + banner: true, + resources: true, + additionalInfo: true + } + } + } + }); + return locations.map((location) => { + return this.formatLocation(location); + }); + } catch (error) { + return this.handleErrors(error); + } + }; + + /** + * This method formats the event location object and segregates its events into past and upcoming events. + * @param location The event location object retrieved from the database + * @returns The formatted location object with pastEvents and upcomingEvents fields + * - Past and upcoming events are based on the current date and the location's timezone. + */ + private formatLocation(location: PLEventLocationWithEvents): FormattedLocationWithEvents { + return { + ...location, + ...this.segregateEventsByTime(location.events, location.timezone) + } + }; + + /** + * This method separates the events of a location into past and upcoming based on the timezone. + * @param events An array of event objects associated with the location + * @param timezone The timezone of the location + * @returns An object containing two arrays: pastEvents and upcomingEvents + * - Events are classified as past or upcoming depending on whether their start date is before or after the current time. + */ + private segregateEventsByTime(events: PLEvent[], timezone: string): { pastEvents: PLEvent[], upcomingEvents: PLEvent[] } { + const currentDateTimeInZone = moment().tz(timezone); + const pastEvents:any = []; + const upcomingEvents:any = []; + events.forEach((event) => { + const eventStartDateInZone = moment.utc(event.startDate).tz(timezone); + const eventEndDateInZone = moment.utc(event.endDate).tz(timezone); + if (eventEndDateInZone.isBefore(currentDateTimeInZone)) { + pastEvents.push({ + ...event, + startDate: eventStartDateInZone.format(), + endDate: eventEndDateInZone.format() + }); + } else { + upcomingEvents.push({ + ...event, + startDate: eventStartDateInZone.format(), + endDate: eventEndDateInZone.format() + }); + } + }); + return { pastEvents, upcomingEvents }; + }; + + /** + * This method handles errors and throws custom exceptions based on Prisma error codes. + * @param error The error object caught during database operations + * @param message Optional additional message to include in the exception + * - Throws NotFoundException if the error is related to a missing record (Prisma error code 'P2025'). + * - Logs the error using the logger service before throwing exceptions. + */ + private handleErrors(error, message?: string): any { + this.logger.error(error); + if (error instanceof Prisma.PrismaClientKnownRequestError) { + switch (error?.code) { + case 'P2025': + throw new NotFoundException('Pl Event location is not found with uid:' + message); + default: + throw error; + } + } + throw error; + }; +} diff --git a/apps/web-api/src/pl-events/pl-event-locations.types.ts b/apps/web-api/src/pl-events/pl-event-locations.types.ts new file mode 100644 index 000000000..4e5ba0859 --- /dev/null +++ b/apps/web-api/src/pl-events/pl-event-locations.types.ts @@ -0,0 +1,31 @@ +import { Prisma } from '@prisma/client'; + +// Define the type for location with selected fields for events +export type PLEventLocationWithEvents = Prisma.PLEventLocationGetPayload<{ + include: { + events: { + select: { + slugURL: true, + uid: true, + name: true; + type: true; + description: true; + startDate: true; + endDate: true; + logo: true; + banner: true; + resources: true; + additionalInfo: true; + }; + }; + }; +}>; + +// Extract the event type from PLEventLocationWithEvents +export type PLEvent = PLEventLocationWithEvents['events'][number]; + +// Define the extended LocationWithEvents type with past and upcoming events +export type FormattedLocationWithEvents = PLEventLocationWithEvents & { + pastEvents: PLEvent[]; + upcomingEvents: PLEvent[]; +}; diff --git a/apps/web-api/src/pl-events/pl-events.controller.ts b/apps/web-api/src/pl-events/pl-events.controller.ts index 57cd7785c..959dc2fb5 100644 --- a/apps/web-api/src/pl-events/pl-events.controller.ts +++ b/apps/web-api/src/pl-events/pl-events.controller.ts @@ -4,11 +4,13 @@ import { Api, initNestServer, ApiDecorator } from '@ts-rest/nest'; import { Request } from 'express'; import { apiEvents } from 'libs/contracts/src/lib/contract-pl-events'; import { + PLEventLocationQueryParams, + ResponsePLEventLocationWithRelationsSchema, PLEventQueryParams, ResponsePLEventSchemaWithRelationsSchema, - ResponsePLEventSchema, CreatePLEventGuestSchemaDto, - UpdatePLEventGuestSchemaDto + UpdatePLEventGuestSchemaDto, + DeletePLEventGuestsSchemaDto } from 'libs/contracts/src/schema'; import { ApiQueryFromZod } from '../decorators/api-query-from-zod'; import { ApiOkResponseFromZod } from '../decorators/api-response-from-zod'; @@ -19,34 +21,40 @@ import { ZodValidationPipe } from 'nestjs-zod'; import { PrismaQueryBuilder } from '../utils/prisma-query-builder'; import { prismaQueryableFieldsFromZod } from '../utils/prisma-queryable-fields-from-zod'; import { NoCache } from '../decorators/no-cache.decorator'; -import {MembersService} from '../members/members.service'; +import { MembersService } from '../members/members.service'; +import { PLEventLocationsService } from './pl-event-locations.service'; +import { PLEventGuestsService } from './pl-event-guests.service'; const server = initNestServer(apiEvents); type RouteShape = typeof server.routeShapes; @Controller() export class PLEventsController { - constructor(private readonly eventService: PLEventsService, private memberService: MembersService) {} + constructor( + private readonly memberService: MembersService, + private readonly eventService: PLEventsService, + private readonly eventLocationService: PLEventLocationsService, + private readonly eventGuestService: PLEventGuestsService + ) {} - @Api(server.route.getPLEvents) - @ApiQueryFromZod(PLEventQueryParams) - @ApiOkResponseFromZod(ResponsePLEventSchemaWithRelationsSchema.array()) - findAll(@Req() request: Request) { - const queryableFields = prismaQueryableFieldsFromZod( - ResponsePLEventSchema - ); - const builder = new PrismaQueryBuilder(queryableFields); - const builtQuery = builder.build(request.query); - return this.eventService.getPLEvents(builtQuery); + @Api(server.route.getPLEventGuestsByLocation) + @UseGuards(UserAuthValidateGuard) + @NoCache() + findPLEventGuestsByLocation( + @Req() request: Request, + @Param('uid') locationUid: string + ) { + const { type } = request.query; + return this.eventGuestService.getPLEventGuestsByLocationAndType(locationUid, type as string, request["isUserLoggedIn"]); } - @Api(server.route.getPLEvent) + @Api(server.route.getPLEventBySlug) @ApiParam({ name: 'slug', type: 'string' }) @ApiOkResponseFromZod(ResponsePLEventSchemaWithRelationsSchema) @UseGuards(UserAuthValidateGuard) @NoCache() async findOne( - @ApiDecorator() { params: { slug } }: RouteShape['getPLEvent'], + @ApiDecorator() { params: { slug } }: RouteShape['getPLEventBySlug'], @Req() request: Request ) { const event = await this.eventService.getPLEventBySlug(slug, request["isUserLoggedIn"]); @@ -56,11 +64,11 @@ export class PLEventsController { return event; } - @Api(server.route.createPLEventGuest) + @Api(server.route.createPLEventGuestByLocation) @UsePipes(ZodValidationPipe) @UseGuards(UserTokenValidation) - async createPLEventGuest( - @Param('slug') slug: string, + async createPLEventGuestByLocation( + @Param("uid") locationUid, @Body() body: CreatePLEventGuestSchemaDto, @Req() request ): Promise { @@ -69,44 +77,61 @@ export class PLEventsController { const result = await this.memberService.isMemberPartOfTeams(member, [body.teamUid]) || await this.memberService.checkIfAdminUser(member); if (!result) { - throw new ForbiddenException(`Member with email ${userEmail} is not part of team with uid ${body.teamUid} or isn't admin`); + throw new ForbiddenException(`Member with email ${userEmail} is not part of + team with uid ${body.teamUid} or isn't admin.`); } - return await this.eventService.createPLEventGuest(body as any, slug, member); + const location = await this.eventLocationService.getPLEventLocationByUid(locationUid); + if ( + !this.memberService.checkIfAdminUser(member) && + !this.eventGuestService.checkIfEventsAreUpcoming(location.upcomingEvents, body.events) + ) { + throw new ForbiddenException(`Member with email ${userEmail} isn't admin to access past events or future events`); + } + return await this.eventGuestService.createPLEventGuestByLocation(body, member); } - @Api(server.route.modifyPLEventGuest) + @Api(server.route.modifyPLEventGuestByLocation) @UsePipes(ZodValidationPipe) @UseGuards(UserTokenValidation) - async modifyPLEventGuest( - @Param('slug') slug: string, - @Param('uid') uid: string, + async modifyPLEventGuestByLocation( + @Param("uid") locationUid, @Body() body: UpdatePLEventGuestSchemaDto, @Req() request ) { const userEmail = request["userEmail"]; + const { type } = request.query; const member: any = await this.memberService.findMemberByEmail(request["userEmail"]); const result = await this.memberService.isMemberPartOfTeams(member, [body.teamUid]) || await this.memberService.checkIfAdminUser(member); if (!result) { throw new ForbiddenException(`Member with email ${userEmail} is not part of team with uid ${body.teamUid} or isn't admin`); } - return await this.eventService.modifyPLEventGuestByUid(uid, body as any, slug, member); + const location = await this.eventLocationService.getPLEventLocationByUid(locationUid); + if ( + !this.memberService.checkIfAdminUser(member) && + !this.eventGuestService.checkIfEventsAreUpcoming(location.upcomingEvents, body.events) + ) { + throw new ForbiddenException(`Member with email ${userEmail} isn't admin to access past events or future events`); + } + return await this.eventGuestService.modifyPLEventGuestByLocation(body, location, member, type); } - @Api(server.route.deletePLEventGuests) + @Api(server.route.deletePLEventGuestsByLocation) @UsePipes(ZodValidationPipe) @UseGuards(UserTokenValidation) - async deletePLEventGuests( - @Body() body, + async deletePLEventGuestsByLocation( + @Param("uid") locationUid, + @Body() body: DeletePLEventGuestsSchemaDto, @Req() request ) { const userEmail = request["userEmail"]; const member: any = await this.memberService.findMemberByEmail(request["userEmail"]); const result = await this.memberService.checkIfAdminUser(member); - if (!result) { - throw new ForbiddenException(`Member with email ${userEmail} is not admin `); + if (!result && body.membersAndEvents?.length === 0 + && body.membersAndEvents[0]?.memberUid != member.uid) { + throw new ForbiddenException(`Member with email ${userEmail} is not admin`); } - return await this.eventService.deletePLEventGuests(body.guests); + return await this.eventGuestService.deletePLEventGuests(body.membersAndEvents); } @Api(server.route.getPLEventsByLoggedInMember) @@ -121,4 +146,16 @@ export class PLEventsController { return await this.eventService.getPLEventsByMember(member); } + @Api(server.route.getPLEventLocations) + @ApiQueryFromZod(PLEventLocationQueryParams) + @ApiOkResponseFromZod(ResponsePLEventLocationWithRelationsSchema.array()) + @NoCache() + findLocations(@Req() request: Request) { + const queryableFields = prismaQueryableFieldsFromZod( + ResponsePLEventLocationWithRelationsSchema + ); + const builder = new PrismaQueryBuilder(queryableFields); + const builtQuery = builder.build(request.query); + return this.eventLocationService.getPLEventLocations(builtQuery); + } } diff --git a/apps/web-api/src/pl-events/pl-events.module.ts b/apps/web-api/src/pl-events/pl-events.module.ts index a4d69f1b0..1290ca0fc 100644 --- a/apps/web-api/src/pl-events/pl-events.module.ts +++ b/apps/web-api/src/pl-events/pl-events.module.ts @@ -1,15 +1,22 @@ import { Module } from '@nestjs/common'; import { PLEventsController } from './pl-events.controller'; +import { PLEventLocationsService } from './pl-event-locations.service'; import { PLEventsService } from './pl-events.service'; +import { PLEventGuestsService } from './pl-event-guests.service'; import { MembersModule } from '../members/members.module'; - @Module({ controllers: [PLEventsController], providers: [ PLEventsService, + PLEventLocationsService, + PLEventGuestsService + ], + exports: [ + PLEventsService, + PLEventLocationsService, + PLEventGuestsService ], - exports: [PLEventsService], imports:[MembersModule] }) export class PLEventsModule {} diff --git a/apps/web-api/src/pl-events/pl-events.service.ts b/apps/web-api/src/pl-events/pl-events.service.ts index af4a62b0c..6d50e11c1 100644 --- a/apps/web-api/src/pl-events/pl-events.service.ts +++ b/apps/web-api/src/pl-events/pl-events.service.ts @@ -1,228 +1,139 @@ -import { Injectable, BadRequestException, ConflictException, NotFoundException, Inject, CACHE_MANAGER } from '@nestjs/common'; +import { Injectable, BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { LogService } from '../shared/log.service'; import { PrismaService } from '../shared/prisma.service'; -import { Prisma } from '@prisma/client'; -import { Cache } from 'cache-manager'; -import { MembersService } from '../members/members.service'; - +import { Prisma, PLEvent, Member } from '@prisma/client'; +import { PLEventGuestsService } from './pl-event-guests.service'; @Injectable() export class PLEventsService { constructor( private prisma: PrismaService, private logger: LogService, - private memberService: MembersService, - @Inject(CACHE_MANAGER) private cacheService: Cache + private eventGuestsService: PLEventGuestsService ) {} - async getPLEvents(queryOptions: Prisma.PLEventFindManyArgs) { + /** + * This method retrieves multiple events based on the provided query options. + * @param queryOptions Options for querying events, including filters and sorting + * @returns An array of event objects with additional details such as logo, banner, event guests, and location. + */ + async getPLEvents(queryOptions: Prisma.PLEventFindManyArgs): Promise { return await this.prisma.pLEvent.findMany({ ...queryOptions, include: { logo: true, banner: true, eventGuests: { - select:{ + select: { eventUid: true } - } + }, + location: true } }); }; + /** + * This method retrieves a specific event by its slug URL, with different details based on whether the user is logged in. + * @param slug The slug URL of the event + * @param isUserLoggedIn A boolean indicating whether the user is logged in + * @returns The event object with its logo, banner, and event guests (with sensitive data restricted based on login status). + * - Filters private resources and applies member preferences on event guests' details like telegramId and office hours. + * - Throws NotFoundException if the event is not found with the given slug. + */ async getPLEventBySlug( slug: string, - isUserLoggedIn: Boolean - ) { - const plEvent = await this.prisma.pLEvent.findUnique({ - where: { slugURL: slug }, - include: { - logo: true, - banner: true, - eventGuests: { - select: isUserLoggedIn ? { - uid: true, - reason: true, - telegramId: true, - memberUid: true, - topics: true, - officeHours: true, - additionalInfo: true, - member: { - select:{ - name: true, - image: true, - telegramHandler: true, - teamMemberRoles: { - select:{ - team: { - select:{ - uid: true, - name: true, - logo: true + isUserLoggedIn: boolean + ): Promise { + try { + const plEvent = await this.prisma.pLEvent.findUniqueOrThrow({ + where: { slugURL: slug }, + include: { + logo: { select: { url: true } }, + banner: { select: { url: true } }, + eventGuests: { + select: { + uid: true, + reason: true, + memberUid: true, + teamUid: true, + topics: true, + additionalInfo: true, + isHost: true, + isSpeaker: true, + createdAt: true, + telegramId: isUserLoggedIn ? true : false, + officeHours: isUserLoggedIn ? true : false, + event: { + include: { + logo: { select: { url: true } } + } + }, + member: { + select:{ + image: { select: { url: true } }, + name: true, + preferences: true, + telegramHandler: isUserLoggedIn ? true : false, + officeHours: isUserLoggedIn ? true : false, + teamMemberRoles: { + select:{ + team: { + select:{ + uid: true, + name: true, + logo: { select: { url: true } } + } } } - } - }, - preferences: true, - officeHours: true, - projectContributions: { - select:{ - project:{ - select:{ - name: true, - isDeleted: true + }, + projectContributions: { + select:{ + project:{ + select:{ + name: true, + isDeleted: true + } } } - } - }, - createdProjects:{ - select: { - name: true, - isDeleted: true + }, + createdProjects:{ + select: { + name: true, + isDeleted: true + } } } + }, + team: { + select:{ + uid: true, + name: true, + logo: { select: { url: true } } + } } - }, - teamUid: true, - team: { - select:{ - uid: true, - name: true, - logo: true - } - }, - createdAt: true, - }: - { - teamUid: true, - team: { - select:{ - name: true, - logo: true - } - }, - createdAt: true + } } } - } - }); - this.filterPrivateResources(plEvent, isUserLoggedIn); - this.restrictTelegramBasedOnMemberPreference(plEvent, isUserLoggedIn); - this.restrictOfficeHours(plEvent, isUserLoggedIn); - return plEvent; - }; - - filterPrivateResources(plEvent, isUserLoggedIn) { - if (plEvent?.resources && plEvent?.resources.length && !isUserLoggedIn) { - plEvent.resources = plEvent?.resources.filter((resource:any) => { - return !resource.isPrivate - }); - } - return plEvent; - } - - restrictTelegramBasedOnMemberPreference(plEvent, isUserLoggedIn) { - if (isUserLoggedIn && plEvent?.eventGuests) { - plEvent.eventGuests = plEvent.eventGuests.map((guest:any) => { - if (!guest.telegramId) { - delete guest.member.telegramHandler; - return guest; - } - if (!guest.member.preferences) { - return guest; - } - if (!guest.member.preferences.showTelegram) { - delete guest.member.telegramHandler; - delete guest.telegramId; - } - return guest; - }); - } - return plEvent; - } - - restrictOfficeHours(plEvent, isUserLoggedIn) { - if (plEvent?.eventGuests && isUserLoggedIn) { - plEvent.eventGuests = plEvent.eventGuests.map((guest:any) => { - if (!guest.officeHours) { - delete guest.member.officeHours; - } - return guest; - }); - } - return plEvent; - } - - async createPLEventGuest( - guest: Prisma.PLEventGuestUncheckedCreateInput, - slug: string, - member - ) { - try { - const event: any = await this.getPLEventBySlug(slug, true); - const isAdmin = this.memberService.checkIfAdminUser(member); - await this.updateMemberDetails(guest, member, isAdmin); - await this.prisma.pLEventGuest.create({ - data:{ - ...guest, - memberUid: isAdmin ? guest.memberUid : member.uid, - eventUid: event?.uid - } }); - await this.cacheService.reset(); - return { - msg: "success" - }; - } catch(err) { - this.handleErrors(err); - } - }; - - async modifyPLEventGuestByUid( - uid: string, - guest: Prisma.PLEventGuestUncheckedCreateInput, - slug: string, - member - ) { - try { - const event: any = await this.getPLEventBySlug(slug, true); - const isAdmin = this.memberService.checkIfAdminUser(member); - await this.updateMemberDetails(guest, member, isAdmin); - return await this.prisma.pLEventGuest.update({ - where:{ uid }, - data:{ - ...guest, - memberUid: this.memberService.checkIfAdminUser(member) ? guest.memberUid : member.uid, - eventUid: event?.uid - } - }); - } catch(err) { - this.handleErrors(err); - } + if (plEvent) { + this.filterPrivateResources(plEvent, isUserLoggedIn); + plEvent.eventGuests = this.eventGuestsService.restrictTelegramBasedOnMemberPreference(plEvent?.eventGuests, isUserLoggedIn); + // plEvent.eventGuests = this.eventGuestsService.restrictOfficeHours(plEvent?.eventGuests, isUserLoggedIn); + } + return plEvent; + } catch(err) { + return this.handleErrors(err, slug); + } }; - async deletePLEventGuests( - guestUids, - ) { - try { - await this.prisma.pLEventGuest.deleteMany({ - where: { - uid: { - in: guestUids ? guestUids : [] - } - } - }); - await this.cacheService.reset(); - return { - msg: `success` - }; - } catch(err) { - this.handleErrors(err); - } - } - - async getPLEventsByMember(member) { + /** + * This method retrieves events associated with a specific member. + * @param member The member object, including the member UID + * @returns An array of event objects where the member is a guest + * - Throws errors if there are issues with the query, including validation or database errors. + */ + async getPLEventsByMember(member: Member): Promise { try { return this.prisma.pLEvent.findMany({ where: { @@ -231,25 +142,35 @@ export class PLEventsService { memberUid: member?.uid } } - }, + } }) } catch(err) { - this.handleErrors(err); + return this.handleErrors(err); } } - async updateMemberDetails(guest, member, isAdmin) { - if (isAdmin) { - const guestMember = await this.memberService.findOne(guest.memberUid); - await this.memberService.updateTelegramIfChanged(guestMember, guest.telegramId); - await this.memberService.updateOfficeHoursIfChanged(guestMember, guest.officeHours); - } else { - await this.memberService.updateTelegramIfChanged(member, guest.telegramId); - await this.memberService.updateOfficeHoursIfChanged(member, guest.officeHours); + /** + * This method filters out private resources from an event's resources if the user is not logged in. + * @param plEvent The event object containing the resources array + * @param isUserLoggedIn A boolean indicating whether the user is logged in + * @returns The event object with private resources removed for non-logged-in users + */ + filterPrivateResources(plEvent: PLEvent, isUserLoggedIn: boolean) { + if (plEvent?.resources && plEvent?.resources.length && !isUserLoggedIn) { + plEvent.resources = plEvent?.resources.filter((resource:any) => { + return !resource.isPrivate + }); } - } - - private handleErrors(error, message?) { + return plEvent; + }; + + /** + * This method handles errors that occur during database operations, specifically Prisma-related errors. + * @param error The error object caught during operations + * @param message Optional additional message to include in the exception + * - Throws specific exceptions like ConflictException for unique constraint violations, BadRequestException for foreign key or validation errors, and NotFoundException if an event is not found. + */ + private handleErrors(error, message?):any { this.logger.error(error); if (error instanceof Prisma.PrismaClientKnownRequestError) { switch (error?.code) { diff --git a/apps/web-api/src/projects/projects.controller.ts b/apps/web-api/src/projects/projects.controller.ts index 49c7a6175..3bb5881c0 100644 --- a/apps/web-api/src/projects/projects.controller.ts +++ b/apps/web-api/src/projects/projects.controller.ts @@ -53,7 +53,8 @@ export class ProjectsController { builtQuery.where = { AND: [ builtQuery.where ? builtQuery.where : {}, - this.projectsService.buildFocusAreaFilters(focusAreas) + this.projectsService.buildFocusAreaFilters(focusAreas), + this.projectsService.buildRecentProjectsFilter(req.query) ] } return this.projectsService.getProjects(builtQuery); diff --git a/apps/web-api/src/projects/projects.service.ts b/apps/web-api/src/projects/projects.service.ts index 9e07d0322..ea189f53d 100644 --- a/apps/web-api/src/projects/projects.service.ts +++ b/apps/web-api/src/projects/projects.service.ts @@ -343,6 +343,7 @@ export class ProjectsService { this.buildNameFilter(name, filter); this.buildFundingFilter(lookingForFunding, filter); this.buildMaintainingTeamFilter(team, filter); + this.buildRecentProjectsFilter(query, filter); return { AND: filter }; @@ -374,4 +375,32 @@ export class ProjectsService { }); } } + + /** + * Constructs a dynamic filter query for retrieving recent projects based on the 'is_recent' query parameter. + * If 'is_recent' is set to 'true', it creates a 'createdAt' filter to retrieve records created within a + * specified number of days. The number of days is configured via an environment variable. + * + * If a filter array is passed, it pushes the 'createdAt' filter to the existing filters. + * + * @param queryParams - HTTP request query parameters object + * @param filter - Optional existing filter array to which the recent filter will be added if provided + * @returns The constructed query with a 'createdAt' filter if 'is_recent' is 'true', + * or an empty object if 'is_recent' is not provided or set to 'false'. + */ + buildRecentProjectsFilter(queryParams, filter?) { + const { isRecent } = queryParams; + const recentFilter = { + createdAt: { + gte: new Date(Date.now() - (parseInt(process.env.RECENT_RECORD_DURATION_IN_DAYS || '30') * 24 * 60 * 60 * 1000)) + } + }; + if (isRecent === 'true' && !filter) { + return recentFilter; + } + if (isRecent === 'true' && filter) { + filter.push(recentFilter); + } + return {}; + } } diff --git a/apps/web-api/src/teams/teams.controller.ts b/apps/web-api/src/teams/teams.controller.ts index 8590d0c81..fa7e18ae1 100644 --- a/apps/web-api/src/teams/teams.controller.ts +++ b/apps/web-api/src/teams/teams.controller.ts @@ -39,7 +39,8 @@ export class TeamsController { builtQuery.where = { AND: [ builtQuery.where ? builtQuery.where : {}, - this.teamsService.buildFocusAreaFilters(focusAreas) + this.teamsService.buildFocusAreaFilters(focusAreas), + this.teamsService.buildRecentTeamsFilter(request.query) ] } return this.teamsService.findAll(builtQuery); diff --git a/apps/web-api/src/teams/teams.service.ts b/apps/web-api/src/teams/teams.service.ts index c5c96f609..e1ef48d34 100644 --- a/apps/web-api/src/teams/teams.service.ts +++ b/apps/web-api/src/teams/teams.service.ts @@ -288,7 +288,7 @@ export class TeamsService { technologies, membershipSources, fundingStage, - officeHours + officeHours } = queryParams; const filter:any = []; this.buildNameAndPLNFriendFilter(name, plnFriend, filter); @@ -297,6 +297,7 @@ export class TeamsService { this.buildMembershipSourcesFilter(membershipSources, filter); this.buildFundingStageFilter(fundingStage, filter); this.buildOfficeHoursFilter(officeHours, filter); + this.buildRecentTeamsFilter(queryParams, filter); return { AND: filter }; @@ -396,4 +397,33 @@ export class TeamsService { }); } } + + + /** + * Constructs a dynamic filter query for retrieving recent teams based on the 'is_recent' query parameter. + * If 'is_recent' is set to 'true', it creates a 'createdAt' filter to retrieve records created within a + * specified number of days. The number of days is configured via an environment variable. + * + * If a filter array is passed, it pushes the 'createdAt' filter to the existing filters. + * + * @param queryParams - HTTP request query parameters object + * @param filter - Optional existing filter array to which the recent filter will be added if provided + * @returns The constructed query with a 'createdAt' filter if 'is_recent' is 'true', + * or an empty object if 'is_recent' is not provided or set to 'false'. + */ + buildRecentTeamsFilter(queryParams, filter?) { + const { isRecent } = queryParams; + const recentFilter = { + createdAt: { + gte: new Date(Date.now() - (parseInt(process.env.RECENT_RECORD_DURATION_IN_DAYS || '30') * 24 * 60 * 60 * 1000)) + } + }; + if (isRecent === 'true' && !filter) { + return recentFilter; + } + if (isRecent === 'true' && filter) { + filter.push(recentFilter); + } + return {}; + } } diff --git a/apps/web-app/constants.ts b/apps/web-app/constants.ts index df280dc41..f925b0ee0 100644 --- a/apps/web-app/constants.ts +++ b/apps/web-app/constants.ts @@ -608,4 +608,5 @@ export const EVENT_TYPE = { INVITE_ONLY:"INVITE_ONLY" } +// Protoshpere URL export const OH_GUIDELINE_URL = "https://protosphere.plnetwork.io/posts/Office-Hours-Guidelines-and-Tips-clsdgrbkk000ypocoqsceyfaq" \ No newline at end of file diff --git a/libs/contracts/src/lib/contract-member.ts b/libs/contracts/src/lib/contract-member.ts index bdb4eede6..7e2b2352e 100644 --- a/libs/contracts/src/lib/contract-member.ts +++ b/libs/contracts/src/lib/contract-member.ts @@ -46,6 +46,15 @@ export const apiMembers = contract.router({ }, summary: 'Update member preference', }, + updateMember: { + method: 'PATCH', + path: `${getAPIVersionAsPath('1')}/members/:uid`, + body: contract.body(), + responses: { + 200: contract.response(), + }, + summary: 'Update member', + }, getMemberPreferences: { method: 'GET', path: `${getAPIVersionAsPath('1')}/members/:uid/preferences`, diff --git a/libs/contracts/src/lib/contract-pl-events.ts b/libs/contracts/src/lib/contract-pl-events.ts index b34b23a1f..71ba9aca7 100644 --- a/libs/contracts/src/lib/contract-pl-events.ts +++ b/libs/contracts/src/lib/contract-pl-events.ts @@ -1,58 +1,50 @@ import { initContract } from '@ts-rest/core'; import { PLEventDetailQueryParams, - PLEventQueryParams, ResponsePLEventSchemaWithRelationsSchema, + PLEventLocationQueryParams, + ResponsePLEventLocationWithRelationsSchema } from '../schema'; import { getAPIVersionAsPath } from '../utils/versioned-path'; const contract = initContract(); export const apiEvents = contract.router({ - getPLEvents: { + getPLEventBySlug: { method: 'GET', - path: `${getAPIVersionAsPath('1')}/irl/events`, - query: PLEventQueryParams, - responses: { - 200: ResponsePLEventSchemaWithRelationsSchema.array(), - }, - summary: 'Get all pl events', - }, - getPLEvent: { - method: 'GET', - path: `${getAPIVersionAsPath('1')}/irl/events/:slug`, + path: `${getAPIVersionAsPath('1')}/irl/locations/:uid/events/:slug`, query: PLEventDetailQueryParams, responses: { 200: ResponsePLEventSchemaWithRelationsSchema, }, - summary: 'Get a pl event', + summary: 'Get a pl event with guests by slug and location', }, - createPLEventGuest: { + createPLEventGuestByLocation: { method: 'POST', - path: `${getAPIVersionAsPath('1')}/irl/events/:slug/guest`, + path: `${getAPIVersionAsPath('1')}/irl/locations/:uid/guests`, body: contract.body(), responses: { 200: contract.response(), }, - summary: 'create a guest in pl event', + summary: 'create guests in pl events', }, - modifyPLEventGuest: { + modifyPLEventGuestByLocation: { method: 'PUT', - path: `${getAPIVersionAsPath('1')}/irl/events/:slug/guest/:uid`, + path: `${getAPIVersionAsPath('1')}/irl/locations/:uid/guests/:guestUid`, body: contract.body(), responses: { 200: contract.response(), }, - summary: 'Modify a guest in pl event', + summary: 'Modify guests in pl events', }, - deletePLEventGuests: { + deletePLEventGuestsByLocation: { method: 'POST', - path: `${getAPIVersionAsPath('1')}/irl/events/:slug/guests`, + path: `${getAPIVersionAsPath('1')}/irl/locations/:uid/events/guests`, body: contract.body(), responses: { 200: contract.response(), }, - summary: 'delete a list of guests in pl event', + summary: 'delete guests from events', }, getPLEventsByLoggedInMember: { method: 'GET', @@ -62,5 +54,23 @@ export const apiEvents = contract.router({ 200: ResponsePLEventSchemaWithRelationsSchema, }, summary: 'Get events by logged in member', + }, + getPLEventLocations: { + method: 'GET', + path: `${getAPIVersionAsPath('1')}/irl/locations`, + query: PLEventLocationQueryParams, + responses: { + 200: ResponsePLEventLocationWithRelationsSchema.array(), + }, + summary: 'Get all pl event locations' + }, + getPLEventGuestsByLocation: { + method: 'GET', + path: `${getAPIVersionAsPath('1')}/irl/locations/:uid/guests`, + query: contract.query, + responses: { + 200: contract.response(), + }, + summary: 'Get pl event guests by location and type', } }); diff --git a/libs/contracts/src/schema/index.ts b/libs/contracts/src/schema/index.ts index 22e482eaf..07e426dbb 100644 --- a/libs/contracts/src/schema/index.ts +++ b/libs/contracts/src/schema/index.ts @@ -22,4 +22,5 @@ export * from './project-focus-areas'; export * from './member-interaction'; export * from './member-follow-up'; export * from './member-feedback'; -export * from './discovery-question'; \ No newline at end of file +export * from './discovery-question'; +export * from './pl-event-location'; \ No newline at end of file diff --git a/libs/contracts/src/schema/member.ts b/libs/contracts/src/schema/member.ts index 3519ac27b..c7df00a53 100644 --- a/libs/contracts/src/schema/member.ts +++ b/libs/contracts/src/schema/member.ts @@ -74,6 +74,7 @@ export const CreateMemberSchema = MemberSchema.pick({ officeHours: true, plnFriend: true, locationUid: true, + bio: true }); export const MemberRelationalFields = ResponseMemberWithRelationsSchema.pick({ diff --git a/libs/contracts/src/schema/pl-event-guest.ts b/libs/contracts/src/schema/pl-event-guest.ts index d0ae317b9..fd5283508 100644 --- a/libs/contracts/src/schema/pl-event-guest.ts +++ b/libs/contracts/src/schema/pl-event-guest.ts @@ -4,6 +4,32 @@ import { QueryParams } from './query-params'; import { ResponseMemberSchema } from './member'; import { ResponseTeamSchema } from "./team"; +export const CreatePLEventGuestSchema = z.object({ + teamUid: z.string(), + memberUid: z.string(), + telegramId: z.string().optional(), + reason: z.string().optional(), + additionalInfo: z.any(), + topics: z.array(z.string()).optional(), + officeHours: z.string(), + events: z.array(z.object({ + uid: z.string(), + isHost: z.boolean().optional(), + isSpeaker: z.boolean().optional(), + hostSubEvents: z.array( + z.object({ + name: z.string(), + link: z.string().url(), + }) + ).optional(), + speakerSubEvents: z.array( + z.object({ + name: z.string(), + link: z.string().url(), + })).optional() + })) +}); + export const PLEventGuestSchema = z.object({ id: z.number().int(), uid: z.string(), @@ -18,14 +44,6 @@ export const PLEventGuestSchema = z.object({ topics: z.array(z.string()).optional() }); -export const CreatePLEventGuestSchema = PLEventGuestSchema.pick({ - teamUid: true, - telegramId: true, - reason: true, - additionalInfo: true, - topics: true -}); - export const ResponsePLEventGuestSchema = PLEventGuestSchema.omit({ id: true }).strict(); export const ResponsePLEventGuestSchemaWithRelationsSchema = ResponsePLEventGuestSchema.extend({ @@ -45,5 +63,15 @@ export const PLEventGuestQueryParams = QueryParams({ relationalFields: PLEventGuestRelationalFields, }); +export const DeletePLEventGuestsSchema = z.object({ + membersAndEvents: z.array( + z.object({ + memberUid: z.string(), + eventUid: z.string() + } + )) +}); + export class CreatePLEventGuestSchemaDto extends createZodDto(CreatePLEventGuestSchema) {} export class UpdatePLEventGuestSchemaDto extends createZodDto(CreatePLEventGuestSchema) {} +export class DeletePLEventGuestsSchemaDto extends createZodDto(DeletePLEventGuestsSchema) {} \ No newline at end of file diff --git a/libs/contracts/src/schema/pl-event-location.ts b/libs/contracts/src/schema/pl-event-location.ts new file mode 100644 index 000000000..7fac03189 --- /dev/null +++ b/libs/contracts/src/schema/pl-event-location.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; +import { ResponsePLEventSchemaWithRelationsSchema } from './pl-event'; +import { QueryParams, RETRIEVAL_QUERY_FILTERS } from './query-params'; + +export const PLEventLocationSchema = z.object({ + id: z.number().int(), + uid: z.string(), + location: z.string(), + latitude: z.string().optional().nullable(), + longitude: z.string().optional().nullable(), + flag: z.string().optional().nullable(), + icon: z.string().optional().nullable(), + resources: z.array( + z.object({ + name: z.string(), + link: z.string().url(), + description: z.string().optional() + }) + ).optional(), + additionalInfo: z.any(), + priority: z.number().int().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + timezone: z.string(), +}); + +export const PLCreateEventLocationSchema = PLEventLocationSchema.pick({ + location: true, + latitude: true, + longitude: true, + flag: true, + icon: true, + resources: true, + additionalInfo: true, + priority: true, + timezone: true +}); + +export const ResponsePLEventLocationSchema = PLEventLocationSchema.omit({ id: true }).strict(); + +export const ResponsePLEventLocationWithRelationsSchema = ResponsePLEventLocationSchema.extend({ + // events: ResponsePLEventSchemaWithRelationsSchema.array().optional() +}); + +export const PLEventLocationRelationalFields = ResponsePLEventLocationWithRelationsSchema.pick({ + // events: true +}).strip(); + +export const PLEventLocationQueryableFields = ResponsePLEventLocationSchema.keyof(); + +export const PLEventLocationQueryParams = QueryParams({ + queryableFields: PLEventLocationQueryableFields, + relationalFields: PLEventLocationRelationalFields +}); + +export const PLEventLocationDetailQueryParams = PLEventLocationQueryParams.unwrap() + .pick(RETRIEVAL_QUERY_FILTERS) + .optional(); + diff --git a/libs/contracts/src/schema/pl-event.ts b/libs/contracts/src/schema/pl-event.ts index 387ac4fe1..6662b7c8d 100644 --- a/libs/contracts/src/schema/pl-event.ts +++ b/libs/contracts/src/schema/pl-event.ts @@ -2,6 +2,7 @@ import { z, } from "zod"; import { createZodDto } from '@abitia/zod-dto'; import { ResponsePLEventGuestSchema } from "./pl-event-guest"; import { ResponseImageSchema } from "./image"; +import { ResponsePLEventLocationSchema } from "./pl-event-location" import { QueryParams, RETRIEVAL_QUERY_FILTERS } from './query-params'; export const PLEventSchema = z.object({ @@ -30,7 +31,7 @@ export const PLEventSchema = z.object({ additionalInfo: z.any(), startDate: z.string(), endDate: z.string(), - location: z.string(), + locationUid: z.string().optional(), createdAt: z.string(), updatedAt: z.string() }); @@ -45,7 +46,7 @@ export const PLCreateEventSchema = PLEventSchema.pick({ resources: true, startDate: true, endDate: true, - location: true + locationUid: true }); export const ResponsePLEventSchema = PLEventSchema.omit({ id: true }).strict(); @@ -53,13 +54,15 @@ export const ResponsePLEventSchema = PLEventSchema.omit({ id: true }).strict(); export const ResponsePLEventSchemaWithRelationsSchema = ResponsePLEventSchema.extend({ logo: ResponseImageSchema.optional(), banner: ResponseImageSchema.optional(), - eventGuests: ResponsePLEventGuestSchema.array().optional() + eventGuests: ResponsePLEventGuestSchema.array().optional(), + location: ResponsePLEventLocationSchema.optional() }); export const PLEventRelationalFields = ResponsePLEventSchemaWithRelationsSchema.pick({ logo: true, banner: true, - eventGuests: true + eventGuests: true, + location: true }).strip(); export const PLEventQueryableFields = ResponsePLEventSchema.keyof(); diff --git a/package.json b/package.json index d3f594639..40c250486 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "lexical": "^0.12.2", "lodash": "^4.17.21", "md-editor-rt": "^4.8.2", + "moment-timezone": "^0.5.45", "ncsrf": "^1.0.3", "nest-commander": "^3.3.0", "nest-winston": "^1.9.2", diff --git a/yarn.lock b/yarn.lock index 266702892..07284c984 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19110,11 +19110,23 @@ moment-timezone@^0.5.34: dependencies: moment ">= 2.9.0" +moment-timezone@^0.5.45: + version "0.5.45" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.45.tgz#cb685acd56bac10e69d93c536366eb65aa6bcf5c" + integrity sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ== + dependencies: + moment "^2.29.4" + "moment@>= 2.9.0", moment@^2.29.1: version "2.29.4" resolved "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz" integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== +moment@^2.29.4: + version "2.30.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + motion@10.16.2: version "10.16.2" resolved "https://registry.yarnpkg.com/motion/-/motion-10.16.2.tgz#7dc173c6ad62210a7e9916caeeaf22c51e598d21"