diff --git a/apps/api-gateway/src/ecosystem/ecosystem.service.ts b/apps/api-gateway/src/ecosystem/ecosystem.service.ts index 948111655..3464f132c 100755 --- a/apps/api-gateway/src/ecosystem/ecosystem.service.ts +++ b/apps/api-gateway/src/ecosystem/ecosystem.service.ts @@ -19,6 +19,7 @@ import { CreateIntentTemplateDto, UpdateIntentTemplateDto } from '../utilities/d import { GetAllIntentTemplatesDto } from '../utilities/dtos/get-all-intent-templates.dto'; import { IIntentTemplateList } from '@credebl/common/interfaces/intents-template.interface'; import { IPaginationSortingDto, PaginatedResponse } from 'libs/common/src/interfaces/interface'; +import { CreateIntentNoticeDto, UpdateIntentNoticeDto } from '../oid4vc-verification/dtos/create-intent-notice.dto'; @Injectable() export class EcosystemService { @@ -251,4 +252,42 @@ export class EcosystemService { async getCreateEcosystemInvitationStatus(email: string, status: Invitation): Promise { return this.natsClient.sendNatsMessage(this.serviceProxy, 'get-ecosystem-created-status', { email, status }); } + + async createIntentNotice(createIntentNoticeDto: CreateIntentNoticeDto, userDetails: user): Promise { + const payload = { createIntentNoticeDto, userId: userDetails.id }; + return this.natsClient.sendNatsMessage(this.serviceProxy, 'create-intent-notice', payload); + } + + async getIntentNotices(id?: string, intentId?: string): Promise { + return this.natsClient.sendNatsMessage(this.serviceProxy, 'get-intent-notices', { id, intentId }); + } + + async getIntentNoticesByEcosystemId( + ecosystemId: string, + pageNumber: number, + pageSize: number, + search: string, + intentId?: string + ): Promise { + return this.natsClient.sendNatsMessage(this.serviceProxy, 'get-intent-notices-by-ecosystem', { + ecosystemId, + pageNumber, + pageSize, + search, + intentId + }); + } + + async updateIntentNotice( + id: string, + updateIntentNoticeDto: UpdateIntentNoticeDto, + userDetails: user + ): Promise { + const payload = { id, updateIntentNoticeDto, userId: userDetails.id }; + return this.natsClient.sendNatsMessage(this.serviceProxy, 'update-intent-notice', payload); + } + + async deleteIntentNotice(id: string, userDetails: user): Promise { + return this.natsClient.sendNatsMessage(this.serviceProxy, 'delete-intent-notice', { id, userId: userDetails.id }); + } } diff --git a/apps/api-gateway/src/ecosystem/intent/intent.controller.ts b/apps/api-gateway/src/ecosystem/intent/intent.controller.ts index a46a63dbe..1656b9187 100755 --- a/apps/api-gateway/src/ecosystem/intent/intent.controller.ts +++ b/apps/api-gateway/src/ecosystem/intent/intent.controller.ts @@ -47,6 +47,7 @@ import { PaginationDto } from '@credebl/common/dtos/pagination.dto'; import { TrimStringParamPipe } from '@credebl/common/cast.helper'; import { EcosystemService } from '../ecosystem.service'; import { ForbiddenErrorDto } from '../../dtos/forbidden-error.dto'; +import { CreateIntentNoticeDto, UpdateIntentNoticeDto } from '../../oid4vc-verification/dtos/create-intent-notice.dto'; @UseFilters(CustomExceptionFilter) @Controller('intent') @@ -611,4 +612,183 @@ export class IntentController { }; return res.status(HttpStatus.OK).json(finalResponse); } + + @Post('/notice') + @ApiBearerAuth() + @Roles(OrgRoles.ECOSYSTEM_LEAD) + @UseGuards(AuthGuard('jwt'), EcosystemRolesGuard) + @ApiOperation({ summary: 'Create intent notice', description: 'Stores a notice URL associated with an intent.' }) + @ApiResponse({ status: HttpStatus.CREATED, description: 'Intent notice created successfully', type: ApiResponseDto }) + async createIntentNotice( + @Body() createIntentNoticeDto: CreateIntentNoticeDto, + @User() user: PrismaUser, + @Res() res: Response + ): Promise { + const result = await this.ecosystemService.createIntentNotice(createIntentNoticeDto, user); + const finalResponse: IResponse = { + statusCode: HttpStatus.CREATED, + message: ResponseMessages.intentNotice.success.create, + data: result + }; + return res.status(HttpStatus.CREATED).json(finalResponse); + } + + @Get('/notice') + @ApiBearerAuth() + @Roles(OrgRoles.ECOSYSTEM_LEAD, OrgRoles.ECOSYSTEM_MEMBER) + @UseGuards(AuthGuard('jwt'), EcosystemRolesGuard) + @ApiOperation({ + summary: 'Get intent notices', + description: 'Retrieves intent notices. Filter by notice id or intentId (both optional).' + }) + @ApiQuery({ name: 'id', required: false, type: String, description: 'Filter by notice PK UUID (optional)' }) + @ApiQuery({ name: 'intentId', required: false, type: String, description: 'Filter by intent UUID (optional)' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Intent notices fetched successfully', type: ApiResponseDto }) + async getIntentNotices( + @Res() res: Response, + @Query( + 'id', + new ParseUUIDPipe({ + version: '4', + optional: true, + exceptionFactory: (): Error => { + throw new BadRequestException('Invalid notice ID'); + } + }) + ) + id?: string, + @Query( + 'intentId', + new ParseUUIDPipe({ + version: '4', + optional: true, + exceptionFactory: (): Error => { + throw new BadRequestException('Invalid intent ID'); + } + }) + ) + intentId?: string + ): Promise { + const result = await this.ecosystemService.getIntentNotices(id, intentId); + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.intentNotice.success.fetchAll, + data: result + }; + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Get('/ecosystem/:ecosystemId/notice') + @ApiBearerAuth() + @Roles(OrgRoles.ECOSYSTEM_LEAD, OrgRoles.ECOSYSTEM_MEMBER) + @UseGuards(AuthGuard('jwt'), EcosystemRolesGuard) + @ApiOperation({ + summary: 'Get intent notices by ecosystem', + description: 'Retrieves all intent notices for an ecosystem with pagination, search, and optional intent filter.' + }) + @ApiQuery({ name: 'pageNumber', required: false, type: Number, description: 'Page number (default: 1)' }) + @ApiQuery({ name: 'pageSize', required: false, type: Number, description: 'Page size (default: 10, max: 100)' }) + @ApiQuery({ name: 'search', required: false, type: String, description: 'Search by notice URL' }) + @ApiQuery({ name: 'intentId', required: false, type: String, description: 'Filter by intent UUID' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Intent notices fetched successfully', type: ApiResponseDto }) + async getIntentNoticesByEcosystemId( + @Param( + 'ecosystemId', + TrimStringParamPipe, + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.ecosystem.error.invalidFormatOfEcosystemId); + } + }) + ) + ecosystemId: string, + @Query() pageDto: PaginationDto, + @Query( + 'intentId', + new ParseUUIDPipe({ + version: '4', + optional: true, + exceptionFactory: (): Error => { + throw new BadRequestException('Invalid intent ID'); + } + }) + ) + intentId: string, + @Res() res: Response + ): Promise { + const result = await this.ecosystemService.getIntentNoticesByEcosystemId( + ecosystemId, + pageDto.pageNumber, + pageDto.pageSize, + pageDto.search, + intentId + ); + return res.status(HttpStatus.OK).json({ + statusCode: HttpStatus.OK, + message: ResponseMessages.intentNotice.success.fetchAll, + data: result + }); + } + + @Put('/notice/:id') + @ApiBearerAuth() + @Roles(OrgRoles.ECOSYSTEM_LEAD) + @UseGuards(AuthGuard('jwt'), EcosystemRolesGuard) + @ApiOperation({ + summary: 'Update intent notice', + description: 'Updates the notice URL for a given notice ID.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Intent notice updated successfully', type: ApiResponseDto }) + async updateIntentNotice( + @Param( + 'id', + TrimStringParamPipe, + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException('Invalid notice ID'); + } + }) + ) + id: string, + @Body() updateIntentNoticeDto: UpdateIntentNoticeDto, + @User() user: PrismaUser, + @Res() res: Response + ): Promise { + const result = await this.ecosystemService.updateIntentNotice(id, updateIntentNoticeDto, user); + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.intentNotice.success.update, + data: result + }; + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Delete('/notice/:id') + @ApiBearerAuth() + @Roles(OrgRoles.ECOSYSTEM_LEAD) + @UseGuards(AuthGuard('jwt'), EcosystemRolesGuard) + @ApiOperation({ summary: 'Delete intent notice', description: 'Deletes an intent notice by its ID.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Intent notice deleted successfully', type: ApiResponseDto }) + async deleteIntentNotice( + @Param( + 'id', + TrimStringParamPipe, + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException('Invalid notice ID'); + } + }) + ) + id: string, + @User() user: PrismaUser, + @Res() res: Response + ): Promise { + const result = await this.ecosystemService.deleteIntentNotice(id, user); + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.intentNotice.success.delete, + data: result + }; + return res.status(HttpStatus.OK).json(finalResponse); + } } diff --git a/apps/api-gateway/src/oid4vc-verification/dtos/create-intent-notice.dto.ts b/apps/api-gateway/src/oid4vc-verification/dtos/create-intent-notice.dto.ts new file mode 100644 index 000000000..2df30fec1 --- /dev/null +++ b/apps/api-gateway/src/oid4vc-verification/dtos/create-intent-notice.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDefined, IsOptional, IsString, IsUrl, IsUUID } from 'class-validator'; + +export class CreateIntentNoticeDto { + @ApiProperty({ description: 'Intent ID to associate the notice with', example: 'uuid-of-intent' }) + @IsDefined() + @IsUUID() + intentId: string; + + @ApiProperty({ description: 'URL of the notice', example: 'https://example.com/notice' }) + @IsDefined() + @IsString() + noticeUrl: string; + + @ApiPropertyOptional({ description: 'Organization ID (optional)', example: 'uuid-of-org' }) + @IsOptional() + @IsUUID() + orgId?: string; +} + +export class UpdateIntentNoticeDto { + @ApiPropertyOptional({ description: 'URL of the notice', example: 'https://example.com/notice' }) + @IsOptional() + @IsUrl() + noticeUrl?: string; +} diff --git a/apps/api-gateway/src/oid4vc-verification/oid4vc-verification.controller.ts b/apps/api-gateway/src/oid4vc-verification/oid4vc-verification.controller.ts index db9a2b2d0..620348e98 100644 --- a/apps/api-gateway/src/oid4vc-verification/oid4vc-verification.controller.ts +++ b/apps/api-gateway/src/oid4vc-verification/oid4vc-verification.controller.ts @@ -319,6 +319,7 @@ export class Oid4vcVerificationController { description: 'Verification presentation created successfully.', type: ApiResponseDto }) + @ApiQuery({ name: 'ecosystemId', required: true }) @ApiBearerAuth() @Roles(OrgRoles.OWNER) @UseGuards(AuthGuard('jwt'), OrgRolesGuard) @@ -341,19 +342,29 @@ export class Oid4vcVerificationController { }) ) verifierId: string, + @Query( + 'ecosystemId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException('Invalid ecosystem ID'); + } + }) + ) + ecosystemId: string, @User() user: user, @Body() createIntentDto: CreateIntentBasedVerificationDto, @Res() res: Response ): Promise { this.logger.debug( - `[createIntentBasedVerificationPresentation] Called with orgId=${orgId}, verifierId=${verifierId}, intent=${createIntentDto?.intent}, user=${user.id}` + `[createIntentBasedVerificationPresentation] Called with orgId=${orgId}, verifierId=${verifierId}, intent=${createIntentDto?.intent}, ecosystemId=${ecosystemId}, user=${user.id}` ); const presentation = await this.oid4vcVerificationService.createIntentBasedVerificationPresentation( orgId, verifierId, createIntentDto, - user + user, + ecosystemId ); this.logger.debug(`[createIntentBasedVerificationPresentation] Presentation created successfully`); diff --git a/apps/api-gateway/src/oid4vc-verification/oid4vc-verification.service.ts b/apps/api-gateway/src/oid4vc-verification/oid4vc-verification.service.ts index 1fe1c4a95..11d1e37bc 100644 --- a/apps/api-gateway/src/oid4vc-verification/oid4vc-verification.service.ts +++ b/apps/api-gateway/src/oid4vc-verification/oid4vc-verification.service.ts @@ -25,10 +25,20 @@ export class Oid4vcVerificationService { orgId: string, verifierId: string, createIntentDto: CreateIntentBasedVerificationDto, - userDetails: user + userDetails: user, + ecosystemId: string ): Promise { const { intent, responseMode, requestSigner, expectedOrigins } = createIntentDto; - const payload = { orgId, verifierId, intent, responseMode, requestSigner, expectedOrigins, userDetails }; + const payload = { + orgId, + verifierId, + intent, + responseMode, + requestSigner, + expectedOrigins, + userDetails, + ecosystemId + }; this.logger.debug( `[createIntentBasedVerificationPresentation] Called with orgId=${orgId}, verifierId=${verifierId}, intent=${intent}, user=${userDetails?.id}` ); diff --git a/apps/ecosystem/repositories/ecosystem.repository.ts b/apps/ecosystem/repositories/ecosystem.repository.ts index e8142cb69..43ef98d21 100755 --- a/apps/ecosystem/repositories/ecosystem.repository.ts +++ b/apps/ecosystem/repositories/ecosystem.repository.ts @@ -811,11 +811,15 @@ export class EcosystemRepository { } // eslint-disable-next-line camelcase - async getIntentTemplateByIntentAndOrg(intentName: string, verifierOrgId: string): Promise { + async getIntentTemplateByIntentAndOrg( + intentName: string, + verifierOrgId: string, + ecosystemId?: string + ): Promise { try { const template = await this.prisma.intent_templates.findFirst({ where: { - intent: { is: { name: intentName } }, + intent: { is: { name: intentName, ...(ecosystemId && { ecosystemId }) } }, OR: [{ orgId: verifierOrgId }, { orgId: null }] }, select: { @@ -1692,4 +1696,174 @@ export class EcosystemRepository { throw error; } } + + // Intent Notice CRUD + async createIntentNotice(intentId: string, noticeUrl: string, userId: string, orgId?: string): Promise { + try { + return await this.prisma.intent_notices.create({ + data: { + intent: { connect: { id: intentId } }, + ...(orgId && { organisation: { connect: { id: orgId } } }), + noticeUrl, + createdBy: userId, + lastChangedBy: userId + } + }); + } catch (error) { + this.logger.error(`createIntentNotice error: ${error}`); + throw error; + } + } + + async getIntentNotices(id?: string, intentId?: string): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const where: any = { + ...(id && { id }) + }; + + if (intentId) { + const intent = await this.prisma.intents.findUnique({ + where: { id: intentId }, + select: { ecosystemId: true } + }); + where.intent = { + is: { + id: intentId, + ...(intent && { ecosystemId: intent.ecosystemId }) + } + }; + } + + return await this.prisma.intent_notices.findMany({ + where, + include: { + intent: { select: { id: true, name: true, ecosystemId: true } }, + organisation: { select: { name: true, description: true } } + }, + orderBy: { createDateTime: 'desc' } + }); + } catch (error) { + this.logger.error(`getIntentNotices error: ${error}`); + throw error; + } + } + + async getIntentNoticesByEcosystemId( + ecosystemId: string, + pageNumber: number, + pageSize: number, + search: string, + intentId?: string + ): Promise<{ data: object[]; totalPages: number; totalCount: number }> { + try { + const where = { + intent: { ecosystemId }, + ...(intentId && { intentId }), + ...(search && { noticeUrl: { contains: search, mode: 'insensitive' as const } }) + }; + + const [data, totalCount] = await this.prisma.$transaction([ + this.prisma.intent_notices.findMany({ + where, + include: { + intent: { select: { id: true, name: true, ecosystemId: true } }, + organisation: { select: { name: true, description: true } } + }, + orderBy: { createDateTime: 'desc' }, + skip: (pageNumber - 1) * pageSize, + take: pageSize + }), + this.prisma.intent_notices.count({ where }) + ]); + + return { data, totalPages: Math.ceil(totalCount / pageSize), totalCount }; + } catch (error) { + this.logger.error(`getIntentNoticesByEcosystemId error: ${error}`); + throw error; + } + } + + async getIntentNoticeByIntentId(intentId: string, orgId?: string | null): Promise { + try { + const where: { intentId: string; orgId?: string | null } = { intentId }; + if (orgId !== undefined) { + where.orgId = orgId; + } + return await this.prisma.intent_notices.findFirst({ where }); + } catch (error) { + this.logger.error(`getIntentNoticeByIntentId error: ${error}`); + throw error; + } + } + + async intentNoticeExists(intentId: string, orgId: string | null): Promise { + try { + const record = await this.prisma.intent_notices.findFirst({ + where: { intentId, orgId: orgId ?? null } + }); + return Boolean(record); + } catch (error) { + this.logger.error(`intentNoticeSlotExists error: ${error}`); + throw error; + } + } + + async isEcosystemLead(userId: string, ecosystemId: string): Promise { + try { + const record = await this.prisma.ecosystem_orgs.findFirst({ + where: { + userId, + ecosystemId, + deletedAt: null, + ecosystemRole: { name: EcosystemRoles.ECOSYSTEM_LEAD } + } + }); + return Boolean(record); + } catch (error) { + this.logger.error(`isEcosystemLead error: ${error}`); + throw error; + } + } + + async isUserInOrganisation(userId: string, orgId: string): Promise { + try { + const record = await this.prisma.user_org_roles.findFirst({ + where: { userId, orgId } + }); + return Boolean(record); + } catch (error) { + this.logger.error(`isUserInOrganisation error: ${error}`); + throw error; + } + } + + async updateIntentNotice(id: string, noticeUrl: string, userId: string): Promise { + try { + const record = await this.prisma.intent_notices.findFirst({ where: { id } }); + if (!record) { + return null; + } + return await this.prisma.intent_notices.update({ + where: { id: record['id'] }, + data: { noticeUrl, lastChangedBy: userId } + }); + } catch (error) { + this.logger.error(`updateIntentNotice error: ${error}`); + throw error; + } + } + + async deleteIntentNotice(id: string): Promise { + try { + const record = await this.prisma.intent_notices.findFirst({ where: { id } }); + if (!record) { + return null; + } + return await this.prisma.intent_notices.delete({ where: { id: record['id'] } }); + } catch (error) { + this.logger.error(`deleteIntentNotice error: ${error}`); + throw error; + } + } } diff --git a/apps/ecosystem/src/ecosystem.controller.ts b/apps/ecosystem/src/ecosystem.controller.ts index bb803feea..2e4abf818 100755 --- a/apps/ecosystem/src/ecosystem.controller.ts +++ b/apps/ecosystem/src/ecosystem.controller.ts @@ -238,8 +238,13 @@ export class EcosystemController { async getIntentTemplateByIntentAndOrg(payload: { intentName: string; verifierOrgId: string; + ecosystemId?: string; }): Promise { - return this.ecosystemService.getIntentTemplateByIntentAndOrg(payload.intentName, payload.verifierOrgId); + return this.ecosystemService.getIntentTemplateByIntentAndOrg( + payload.intentName, + payload.verifierOrgId, + payload.ecosystemId + ); } @MessagePattern({ cmd: 'update-intent-template' }) @@ -354,4 +359,59 @@ export class EcosystemController { async getCreateEcosystemInvitationStatus(payload: { email: string; status: Invitation }): Promise { return this.ecosystemService.getCreateEcosystemInvitationStatus(payload.email, payload.status); } + + // Intent Notice CRUD + @MessagePattern({ cmd: 'create-intent-notice' }) + async createIntentNotice(payload: { + createIntentNoticeDto: { intentId: string; noticeUrl: string; orgId?: string }; + userId: string; + }): Promise { + const { createIntentNoticeDto, userId } = payload; + return this.ecosystemService.createIntentNotice( + createIntentNoticeDto.intentId, + createIntentNoticeDto.noticeUrl, + userId, + createIntentNoticeDto.orgId + ); + } + + @MessagePattern({ cmd: 'get-intent-notices' }) + async getIntentNotices(payload: { id?: string; intentId?: string }): Promise { + return this.ecosystemService.getIntentNotices(payload.id, payload.intentId); + } + + @MessagePattern({ cmd: 'get-intent-notices-by-ecosystem' }) + async getIntentNoticesByEcosystemId(payload: { + ecosystemId: string; + pageNumber: number; + pageSize: number; + search: string; + intentId?: string; + }): Promise { + const { ecosystemId, pageNumber, pageSize, search, intentId } = payload; + return this.ecosystemService.getIntentNoticesByEcosystemId(ecosystemId, pageNumber, pageSize, search, intentId); + } + + @MessagePattern({ cmd: 'get-intent-notice-by-intent-id' }) + async getIntentNoticeByIntentId(payload: { intentId: string; orgId?: string | null }): Promise { + return this.ecosystemService.getIntentNoticeByIntentId(payload.intentId, payload.orgId); + } + + @MessagePattern({ cmd: 'update-intent-notice' }) + async updateIntentNotice(payload: { + id: string; + updateIntentNoticeDto: { noticeUrl?: string }; + userId: string; + }): Promise { + return this.ecosystemService.updateIntentNotice( + payload.id, + payload.updateIntentNoticeDto.noticeUrl, + payload.userId + ); + } + + @MessagePattern({ cmd: 'delete-intent-notice' }) + async deleteIntentNotice(payload: { id: string; userId: string }): Promise { + return this.ecosystemService.deleteIntentNotice(payload.id, payload.userId); + } } diff --git a/apps/ecosystem/src/ecosystem.helper.ts b/apps/ecosystem/src/ecosystem.helper.ts new file mode 100644 index 000000000..ba3e5adb9 --- /dev/null +++ b/apps/ecosystem/src/ecosystem.helper.ts @@ -0,0 +1,28 @@ +import { HttpStatus } from '@nestjs/common'; +import { RpcException } from '@nestjs/microservices'; + +export async function validateNoticeUrl(noticeUrl: string): Promise { + if (!noticeUrl || !noticeUrl.trim()) { + throw new RpcException({ + statusCode: HttpStatus.BAD_REQUEST, + message: 'noticeUrl must not be empty.' + }); + } + try { + const response = await fetch(noticeUrl); + if (!response.ok) { + throw new RpcException({ + statusCode: HttpStatus.BAD_REQUEST, + message: `noticeUrl is not reachable (HTTP ${response.status}).` + }); + } + } catch (err) { + if (err instanceof RpcException) { + throw err; + } + throw new RpcException({ + statusCode: HttpStatus.BAD_REQUEST, + message: `noticeUrl could not be resolved: ${err?.message ?? 'unreachable'}` + }); + } +} diff --git a/apps/ecosystem/src/ecosystem.service.ts b/apps/ecosystem/src/ecosystem.service.ts index 6825f44fa..363720dbb 100755 --- a/apps/ecosystem/src/ecosystem.service.ts +++ b/apps/ecosystem/src/ecosystem.service.ts @@ -16,6 +16,7 @@ import { import { ClientProxy, RpcException } from '@nestjs/microservices'; import { ClientRegistrationService } from '@credebl/client-registration'; import { EcosystemRepository } from 'apps/ecosystem/repositories/ecosystem.repository'; +import { validateNoticeUrl } from './ecosystem.helper'; import { CreateEcosystemInviteTemplate } from '../templates/create-ecosystem.templates'; import { EmailDto } from '@credebl/common/dtos/email.dto'; import { InviteMemberToEcosystem } from '../templates/invite-member-template'; @@ -728,9 +729,17 @@ export class EcosystemService { } } - async getIntentTemplateByIntentAndOrg(intentName: string, verifierOrgId: string): Promise { + async getIntentTemplateByIntentAndOrg( + intentName: string, + verifierOrgId: string, + ecosystemId?: string + ): Promise { try { - const intentTemplate = await this.ecosystemRepository.getIntentTemplateByIntentAndOrg(intentName, verifierOrgId); + const intentTemplate = await this.ecosystemRepository.getIntentTemplateByIntentAndOrg( + intentName, + verifierOrgId, + ecosystemId + ); if (!intentTemplate) { this.logger.log( `[getIntentTemplateByIntentAndOrg] - No template found for intent ${intentName} and org ${verifierOrgId}` @@ -1005,4 +1014,164 @@ export class EcosystemService { async getCreateEcosystemInvitationStatus(email: string, status: Invitation): Promise { return this.ecosystemRepository.getCreateEcosystemInvitationStatus(email, status); } + + // Intent Notice CRUD + + private async validateEcosystemLead(userId: string, ecosystemId: string): Promise { + const isLead = await this.ecosystemRepository.isEcosystemLead(userId, ecosystemId); + if (!isLead) { + throw new RpcException({ + statusCode: HttpStatus.FORBIDDEN, + message: 'Only Ecosystem Lead can perform this action.' + }); + } + } + + async createIntentNotice(intentId: string, noticeUrl: string, userId: string, orgId?: string): Promise { + try { + await validateNoticeUrl(noticeUrl); + + const intent = await this.ecosystemRepository.findIntentById(intentId); + + if (!intent) { + throw new RpcException({ + statusCode: HttpStatus.NOT_FOUND, + message: ResponseMessages.intentNotice.error.intentNotFound + }); + } + await this.validateEcosystemLead(userId, intent['ecosystemId']); + + if (orgId) { + const orgEcosystemMembership = await this.ecosystemRepository.getEcosystemOrg(intent['ecosystemId'], orgId); + if (!orgEcosystemMembership) { + throw new RpcException({ + statusCode: HttpStatus.FORBIDDEN, + message: 'The provided orgId is not a member or lead of this ecosystem.' + }); + } + } + + const isAlreadyExists = await this.ecosystemRepository.intentNoticeExists(intentId, orgId ?? null); + if (isAlreadyExists) { + const slotLabel = orgId ? `orgId ${orgId}` : 'no orgId'; + throw new RpcException({ + statusCode: HttpStatus.CONFLICT, + message: `An intent notice with ${slotLabel} already exists for this intent.` + }); + } + return await this.ecosystemRepository.createIntentNotice(intentId, noticeUrl, userId, orgId); + } catch (error) { + const errorResponse = ErrorHandler.categorize(error, ResponseMessages.intentNotice.error.create); + this.logger.error( + `[createIntentNotice] ${errorResponse.statusCode}: ${errorResponse.message}`, + ErrorHandler.format(error) + ); + throw new RpcException(errorResponse); + } + } + + async getIntentNotices(id?: string, intentId?: string): Promise { + try { + const records = await this.ecosystemRepository.getIntentNotices(id, intentId); + if (!records || 0 === records.length) { + throw new RpcException({ + statusCode: HttpStatus.NOT_FOUND, + message: ResponseMessages.intentNotice.error.notFound + }); + } + return records; + } catch (error) { + const errorResponse = ErrorHandler.categorize(error, ResponseMessages.intentNotice.error.notFound); + this.logger.error( + `[getIntentNotices] ${errorResponse.statusCode}: ${errorResponse.message}`, + ErrorHandler.format(error) + ); + throw new RpcException(errorResponse); + } + } + + async getIntentNoticesByEcosystemId( + ecosystemId: string, + pageNumber: number, + pageSize: number, + search: string, + intentId?: string + ): Promise { + try { + return await this.ecosystemRepository.getIntentNoticesByEcosystemId( + ecosystemId, + pageNumber, + pageSize, + search, + intentId + ); + } catch (error) { + const errorResponse = ErrorHandler.categorize(error, ResponseMessages.intentNotice.error.notFound); + this.logger.error( + `[getIntentNoticesByEcosystemId] ${errorResponse.statusCode}: ${errorResponse.message}`, + ErrorHandler.format(error) + ); + throw new RpcException(errorResponse); + } + } + + async getIntentNoticeByIntentId(intentId: string, orgId?: string | null): Promise { + try { + return await this.ecosystemRepository.getIntentNoticeByIntentId(intentId, orgId); + } catch (error) { + const errorResponse = ErrorHandler.categorize(error, ResponseMessages.intentNotice.error.notFound); + this.logger.error( + `[getIntentNoticeByIntentId] ${errorResponse.statusCode}: ${errorResponse.message}`, + ErrorHandler.format(error) + ); + throw new RpcException(errorResponse); + } + } + + async updateIntentNotice(id: string, noticeUrl: string, userId: string): Promise { + try { + await validateNoticeUrl(noticeUrl); + const [notice] = await this.ecosystemRepository.getIntentNotices(id); + if (!notice) { + throw new RpcException({ + statusCode: HttpStatus.NOT_FOUND, + message: ResponseMessages.intentNotice.error.notFound + }); + } + const intent = await this.ecosystemRepository.findIntentById(notice['intentId']); + await this.validateEcosystemLead(userId, intent['ecosystemId']); + + return await this.ecosystemRepository.updateIntentNotice(id, noticeUrl, userId); + } catch (error) { + const errorResponse = ErrorHandler.categorize(error, ResponseMessages.intentNotice.error.updateFailed); + this.logger.error( + `[updateIntentNotice] ${errorResponse.statusCode}: ${errorResponse.message}`, + ErrorHandler.format(error) + ); + throw new RpcException(errorResponse); + } + } + + async deleteIntentNotice(id: string, userId: string): Promise { + try { + const [notice] = await this.ecosystemRepository.getIntentNotices(id); + if (!notice) { + throw new RpcException({ + statusCode: HttpStatus.NOT_FOUND, + message: ResponseMessages.intentNotice.error.notFound + }); + } + const intent = await this.ecosystemRepository.findIntentById(notice['intentId']); + await this.validateEcosystemLead(userId, intent['ecosystemId']); + + return await this.ecosystemRepository.deleteIntentNotice(id); + } catch (error) { + const errorResponse = ErrorHandler.categorize(error, ResponseMessages.intentNotice.error.deleteFailed); + this.logger.error( + `[deleteIntentNotice] ${errorResponse.statusCode}: ${errorResponse.message}`, + ErrorHandler.format(error) + ); + throw new RpcException(errorResponse); + } + } } diff --git a/apps/oid4vc-verification/interfaces/intent-notice.interfaces.ts b/apps/oid4vc-verification/interfaces/intent-notice.interfaces.ts new file mode 100644 index 000000000..cf209ecb5 --- /dev/null +++ b/apps/oid4vc-verification/interfaces/intent-notice.interfaces.ts @@ -0,0 +1,8 @@ +export interface CreateIntentNotice { + intentId: string; + noticeUrl: string; +} + +export interface UpdateIntentNotice { + noticeUrl?: string; +} diff --git a/apps/oid4vc-verification/src/oid4vc-verification.controller.ts b/apps/oid4vc-verification/src/oid4vc-verification.controller.ts index 64eeeb825..7354c0d4a 100644 --- a/apps/oid4vc-verification/src/oid4vc-verification.controller.ts +++ b/apps/oid4vc-verification/src/oid4vc-verification.controller.ts @@ -113,8 +113,10 @@ export class Oid4vpVerificationController { requestSigner: IRequestSigner; userDetails: user; expectedOrigins?: string[]; + ecosystemId: string; }): Promise { - const { orgId, verifierId, intent, responseMode, requestSigner, expectedOrigins, userDetails } = payload; + const { orgId, verifierId, intent, responseMode, requestSigner, expectedOrigins, userDetails, ecosystemId } = + payload; this.logger.debug( `[createIntentBasedVerificationPresentation] Received 'oid4vp-intent-based-verification-presentation' for orgId=${orgId}, verifierId=${verifierId}, intent=${intent}, user=${userDetails?.id ?? 'unknown'}` ); @@ -125,6 +127,7 @@ export class Oid4vpVerificationController { responseMode, requestSigner, userDetails, + ecosystemId, expectedOrigins ); } diff --git a/apps/oid4vc-verification/src/oid4vc-verification.helper.ts b/apps/oid4vc-verification/src/oid4vc-verification.helper.ts new file mode 100644 index 000000000..368f0289a --- /dev/null +++ b/apps/oid4vc-verification/src/oid4vc-verification.helper.ts @@ -0,0 +1,21 @@ +export async function fetchConsentNotice(noticeUrl: string, transactionId: string): Promise { + if (!noticeUrl?.trim() || !transactionId?.trim()) { + throw new Error('noticeUrl and transactionId are required and must not be empty.'); + } + + const consentNoticeUrl = `${noticeUrl}?transactionId=${transactionId}`; + + const response = await fetch(consentNoticeUrl); + + if (!response.ok) { + throw new Error(`consentNoticeUrl is not reachable (HTTP ${response.status}).`); + } + + const data = await response.json(); + + if (!data?.consentNoticeUrl) { + throw new Error('consentNoticeUrl is missing in the consent notice response.'); + } + + return data.consentNoticeUrl; +} diff --git a/apps/oid4vc-verification/src/oid4vc-verification.service.ts b/apps/oid4vc-verification/src/oid4vc-verification.service.ts index 644f3d995..060d8365c 100644 --- a/apps/oid4vc-verification/src/oid4vc-verification.service.ts +++ b/apps/oid4vc-verification/src/oid4vc-verification.service.ts @@ -5,6 +5,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase */ +import { fetchConsentNotice } from './oid4vc-verification.helper'; import { BadRequestException, @@ -313,6 +314,7 @@ export class Oid4vpVerificationService extends BaseService { responseMode: string, requestSigner: IRequestSigner, userDetails: user, + ecosystemId: string, expectedOrigins?: string[] ): Promise { this.logger.debug( @@ -339,7 +341,7 @@ export class Oid4vpVerificationService extends BaseService { const templateData = await this.natsClient.sendNatsMessage( this.oid4vpVerificationServiceProxy, 'get-intent-template-by-intent-and-org', - { intentName: intent, verifierOrgId: orgId } + { intentName: intent, verifierOrgId: orgId, ecosystemId } ); if (!templateData) { @@ -405,6 +407,29 @@ export class Oid4vpVerificationService extends BaseService { this.logger.debug( `[createIntentBasedVerificationPresentation] verification presentation created successfully for orgId=${orgId}` ); + if (createdSession) { + const intentId: string = templateData?.intentId; + if (intentId) { + const intentNotice: any = await this.natsClient + .sendNatsMessage(this.oid4vpVerificationServiceProxy, 'get-intent-notice-by-intent-id', { + intentId, + orgId + }) + .catch(() => null); + + if (intentNotice?.noticeUrl) { + createdSession.consentNoticeUrl = await fetchConsentNotice( + intentNotice.noticeUrl, + createdSession.verificationSession.id + ).catch((err) => { + this.logger.warn( + `[createIntentBasedVerificationPresentation] consent notice enrichment failed: ${err?.message}` + ); + return null; + }); + } + } + } return createdSession; } catch (error) { this.logger.error( diff --git a/libs/common/src/response-messages/index.ts b/libs/common/src/response-messages/index.ts index 0153cccdd..efee0dc53 100644 --- a/libs/common/src/response-messages/index.ts +++ b/libs/common/src/response-messages/index.ts @@ -755,6 +755,22 @@ export const ResponseMessages = { invalidId: 'Invalid id.' } }, + intentNotice: { + success: { + create: 'Intent notice created successfully.', + fetch: 'Intent notice fetched successfully.', + fetchAll: 'Intent notices fetched successfully.', + update: 'Intent notice updated successfully.', + delete: 'Intent notice deleted successfully.' + }, + error: { + create: 'Error while creating intent notice.', + intentNotFound: 'Intent not found.', + notFound: 'Intent notice not found.', + updateFailed: 'Error while updating intent notice.', + deleteFailed: 'Error while deleting intent notice.' + } + }, x509: { success: { create: 'x509 certificate created successfully', diff --git a/libs/prisma-service/prisma/migrations/20260312082536_added_intent_notice_table/migration.sql b/libs/prisma-service/prisma/migrations/20260312082536_added_intent_notice_table/migration.sql new file mode 100644 index 000000000..41457702d --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20260312082536_added_intent_notice_table/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "intent_notices" ( + "id" UUID NOT NULL, + "intentId" UUID NOT NULL, + "noticeUrl" TEXT NOT NULL, + "createDateTime" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" UUID NOT NULL, + "lastChangedDateTime" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastChangedBy" TEXT NOT NULL, + + CONSTRAINT "intent_notices_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "intent_notices_intentId_idx" ON "intent_notices"("intentId"); + +-- AddForeignKey +ALTER TABLE "intent_notices" ADD CONSTRAINT "intent_notices_intentId_fkey" FOREIGN KEY ("intentId") REFERENCES "intents"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/libs/prisma-service/prisma/migrations/20260312084927_add_org_id_to_intent_notices/migration.sql b/libs/prisma-service/prisma/migrations/20260312084927_add_org_id_to_intent_notices/migration.sql new file mode 100644 index 000000000..fa9c444f5 --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20260312084927_add_org_id_to_intent_notices/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "intent_notices" ADD COLUMN "orgId" UUID; + +-- CreateIndex +CREATE INDEX "intent_notices_orgId_idx" ON "intent_notices"("orgId"); + +-- AddForeignKey +ALTER TABLE "intent_notices" ADD CONSTRAINT "intent_notices_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organisation"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/libs/prisma-service/prisma/schema.prisma b/libs/prisma-service/prisma/schema.prisma index c878d63cd..9fa0de723 100755 --- a/libs/prisma-service/prisma/schema.prisma +++ b/libs/prisma-service/prisma/schema.prisma @@ -157,6 +157,7 @@ model organisation { oid4vp_presentations oid4vp_presentations[] verification_templates verification_templates[] intent_templates intent_templates[] + intent_notices intent_notices[] ecosystemOrgs ecosystem_orgs[] ecosystem_invitations ecosystem_invitations[] } @@ -718,6 +719,7 @@ model intents { lastChangedBy String @db.Uuid ecosystem ecosystem @relation(fields: [ecosystemId], references: [id]) intentTemplates intent_templates[] + intentNotices intent_notices[] } model intent_templates { @@ -738,6 +740,22 @@ model intent_templates { @@index([templateId]) } +model intent_notices { + id String @id @default(uuid()) @db.Uuid + intentId String @db.Uuid + orgId String? @db.Uuid + noticeUrl String + createDateTime DateTime @default(now()) @db.Timestamptz(6) + createdBy String @db.Uuid + lastChangedDateTime DateTime @default(now()) @db.Timestamptz(6) + lastChangedBy String + intent intents @relation(fields: [intentId], references: [id]) + organisation organisation? @relation(fields: [orgId], references: [id]) + + @@index([intentId]) + @@index([orgId]) +} + model ecosystem { id String @id @default(uuid()) @db.Uuid name String