diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 57c4631..42b9cec 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -46,6 +46,8 @@ import { MaintenanceModule } from './maintenance/maintenance.module'; import { LeadsModule } from './leads/leads.module'; import { CreditsModule } from './credits/credits.module'; import { TeamsModule } from './teams/teams.module'; +import { NpsModule } from './nps/nps.module'; +import { DoorAccessModule } from './integrations/access-control/door-access.module'; @Module({ imports: [ @@ -148,6 +150,8 @@ import { TeamsModule } from './teams/teams.module'; LeadsModule, CreditsModule, TeamsModule, + NpsModule, + DoorAccessModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/bookings/bookings.module.ts b/backend/src/bookings/bookings.module.ts index 3c48f3d..c075fe6 100644 --- a/backend/src/bookings/bookings.module.ts +++ b/backend/src/bookings/bookings.module.ts @@ -22,6 +22,8 @@ import { UserCreditTransaction } from '../credits/entities/credit-transaction.en import { WaitlistModule } from '../waitlist/waitlist.module'; import { Payment } from '../payments/entities/payment.entity'; import { PaystackProvider } from '../payments/providers/paystack.provider'; +import { NpsModule } from '../nps/nps.module'; +import { DoorAccessModule } from '../integrations/access-control/door-access.module'; @Module({ @@ -30,6 +32,8 @@ import { PaystackProvider } from '../payments/providers/paystack.provider'; WorkspacesModule, WaitlistModule, ConfigModule, + NpsModule, + DoorAccessModule, ], controllers: [BookingsController], providers: [ diff --git a/backend/src/bookings/providers/cancel-booking.provider.ts b/backend/src/bookings/providers/cancel-booking.provider.ts index 4ed3cec..116e8c6 100644 --- a/backend/src/bookings/providers/cancel-booking.provider.ts +++ b/backend/src/bookings/providers/cancel-booking.provider.ts @@ -13,6 +13,7 @@ import { User } from '../../users/entities/user.entity'; import { EmailService } from '../../email/email.service'; import { WorkspacesService } from '../../workspaces/workspaces.service'; import { WaitlistService } from '../../waitlist/waitlist.service'; +import { DoorAccessService } from '../../integrations/access-control/door-access.service'; @Injectable() export class CancelBookingProvider { @@ -24,6 +25,7 @@ export class CancelBookingProvider { private readonly emailService: EmailService, private readonly workspacesService: WorkspacesService, private readonly waitlistService: WaitlistService, + private readonly doorAccessService: DoorAccessService, ) {} async cancel( @@ -83,6 +85,10 @@ export class CancelBookingProvider { .notifyNextInQueue(saved.workspaceId) .catch(() => void 0); + if (saved.userId) { + this.doorAccessService.revokeAccess(saved.id).catch(() => void 0); + } + return saved; } } \ No newline at end of file diff --git a/backend/src/bookings/providers/complete-booking.provider.ts b/backend/src/bookings/providers/complete-booking.provider.ts index 2f553f7..999dda3 100644 --- a/backend/src/bookings/providers/complete-booking.provider.ts +++ b/backend/src/bookings/providers/complete-booking.provider.ts @@ -7,12 +7,16 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Booking } from '../entities/booking.entity'; import { BookingStatus } from '../enums/booking-status.enum'; +import { NpsService } from '../../nps/nps.service'; +import { DoorAccessService } from '../../integrations/access-control/door-access.service'; @Injectable() export class CompleteBookingProvider { constructor( @InjectRepository(Booking) private readonly bookingsRepository: Repository, + private readonly npsService: NpsService, + private readonly doorAccessService: DoorAccessService, ) {} async complete(bookingId: string): Promise { @@ -27,6 +31,15 @@ export class CompleteBookingProvider { } booking.status = BookingStatus.COMPLETED; - return this.bookingsRepository.save(booking); + const saved = await this.bookingsRepository.save(booking); + + if (saved.userId) { + this.npsService + .scheduleIfEligible(saved.userId, saved.id, saved.workspaceId, saved.startDate) + .catch(() => void 0); + this.doorAccessService.revokeAccess(saved.id).catch(() => void 0); + } + + return saved; } } diff --git a/backend/src/bookings/providers/confirm-booking.provider.ts b/backend/src/bookings/providers/confirm-booking.provider.ts index e83db7b..5f73d41 100644 --- a/backend/src/bookings/providers/confirm-booking.provider.ts +++ b/backend/src/bookings/providers/confirm-booking.provider.ts @@ -9,6 +9,7 @@ import { Booking } from '../entities/booking.entity'; import { BookingStatus } from '../enums/booking-status.enum'; import { User } from '../../users/entities/user.entity'; import { MembershipStatus } from '../../users/enums/membership-status.enum'; +import { DoorAccessService } from '../../integrations/access-control/door-access.service'; @Injectable() export class ConfirmBookingProvider { @@ -17,6 +18,7 @@ export class ConfirmBookingProvider { private readonly bookingsRepository: Repository, @InjectRepository(User) private readonly usersRepository: Repository, + private readonly doorAccessService: DoorAccessService, ) {} async confirm(bookingId: string): Promise { @@ -43,6 +45,12 @@ export class ConfirmBookingProvider { await this.usersRepository.save(user); } + if (user) { + this.doorAccessService + .grantAccess(booking.id, booking.userId, user.email) + .catch(() => void 0); + } + return booking; } } diff --git a/backend/src/email/email.service.ts b/backend/src/email/email.service.ts index 13d532b..9d5df0c 100644 --- a/backend/src/email/email.service.ts +++ b/backend/src/email/email.service.ts @@ -238,6 +238,19 @@ export class EmailService { ]); } + async sendNpsSurveyEmail( + email: string, + fullName: string, + data: { + workspaceName: string; + bookingDate: string; + surveyUrl: string; + }, + ): Promise { + const html = this.compileTemplate('nps-survey', { fullName, ...data }); + return this.send(email, 'How was your experience? — ManageHub', html); + } + async sendVisitorCheckInEmail( host: User, visitor: Visitor, diff --git a/backend/src/email/templates/nps-survey.hbs b/backend/src/email/templates/nps-survey.hbs new file mode 100644 index 0000000..b67a2c0 --- /dev/null +++ b/backend/src/email/templates/nps-survey.hbs @@ -0,0 +1,24 @@ + + +How was your experience? + +
+

ManageHub

+
+
+

How was your experience?

+

Hi {{fullName}},

+

Your recent booking at {{workspaceName}} on {{bookingDate}} is now complete. We'd love to hear how it went!

+

It takes less than a minute to share your feedback — and it helps us keep improving.

+ +

If the button doesn't work, paste this link into your browser:

+

{{surveyUrl}}

+

Thank you for choosing ManageHub.

+
+ + diff --git a/backend/src/integrations/access-control/crypto.util.ts b/backend/src/integrations/access-control/crypto.util.ts new file mode 100644 index 0000000..84c6960 --- /dev/null +++ b/backend/src/integrations/access-control/crypto.util.ts @@ -0,0 +1,25 @@ +import * as crypto from 'crypto'; + +const ALGORITHM = 'aes-256-cbc'; + +export function encryptApiKey(plaintext: string, hexKey: string): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(hexKey, 'hex'), iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + return `${iv.toString('hex')}:${encrypted.toString('hex')}`; +} + +export function decryptApiKey(ciphertext: string, hexKey: string): string { + const [ivHex, encHex] = ciphertext.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(hexKey, 'hex'), iv); + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(encHex, 'hex')), + decipher.final(), + ]); + return decrypted.toString('utf8'); +} + +export function isEncrypted(value: string): boolean { + return /^[0-9a-f]{32}:[0-9a-f]+$/i.test(value); +} diff --git a/backend/src/integrations/access-control/door-access.controller.ts b/backend/src/integrations/access-control/door-access.controller.ts new file mode 100644 index 0000000..0af61c9 --- /dev/null +++ b/backend/src/integrations/access-control/door-access.controller.ts @@ -0,0 +1,48 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { DoorAccessService } from './door-access.service'; +import { ConfigureAccessDto } from './dto/configure-access.dto'; +import { AccessLogQueryDto } from './dto/access-log-query.dto'; +import { RolesGuard } from '../../auth/guard/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorators'; +import { UserRole } from '../../users/enums/userRoles.enum'; + +@ApiTags('integrations/access') +@ApiBearerAuth() +@UseGuards(RolesGuard) +@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) +@Controller('integrations/access') +export class DoorAccessController { + constructor(private readonly doorAccessService: DoorAccessService) {} + + @Post('configure') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Configure Kisi/Brivo API key (admin only)' }) + async configure(@Body() dto: ConfigureAccessDto) { + const data = await this.doorAccessService.configure(dto); + return { message: 'Access integration configured', data: { provider: data.provider, isEnabled: data.isEnabled, configuredAt: data.configuredAt } }; + } + + @Get('status') + @ApiOperation({ summary: 'Get integration status (admin only)' }) + async status() { + const data = await this.doorAccessService.getStatus(); + return { message: 'Access integration status', data }; + } + + @Get('logs') + @ApiOperation({ summary: 'Paginated credential grant/revoke log (admin only)' }) + async logs(@Query() query: AccessLogQueryDto) { + const data = await this.doorAccessService.getLogs(query); + return { message: 'Access credential logs retrieved', data }; + } +} diff --git a/backend/src/integrations/access-control/door-access.module.ts b/backend/src/integrations/access-control/door-access.module.ts new file mode 100644 index 0000000..9b9ef72 --- /dev/null +++ b/backend/src/integrations/access-control/door-access.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; +import { AccessIntegration } from './entities/access-integration.entity'; +import { AccessCredential } from './entities/access-credential.entity'; +import { DoorAccessService } from './door-access.service'; +import { DoorAccessController } from './door-access.controller'; +import { KisiProvider } from './providers/kisi.provider'; +import { BrivoProvider } from './providers/brivo.provider'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([AccessIntegration, AccessCredential]), + ConfigModule, + ], + controllers: [DoorAccessController], + providers: [DoorAccessService, KisiProvider, BrivoProvider], + exports: [DoorAccessService], +}) +export class DoorAccessModule {} diff --git a/backend/src/integrations/access-control/door-access.service.ts b/backend/src/integrations/access-control/door-access.service.ts new file mode 100644 index 0000000..3110d34 --- /dev/null +++ b/backend/src/integrations/access-control/door-access.service.ts @@ -0,0 +1,185 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Repository } from 'typeorm'; +import { AccessIntegration } from './entities/access-integration.entity'; +import { AccessCredential } from './entities/access-credential.entity'; +import { AccessProvider } from './enums/access-provider.enum'; +import { ConfigureAccessDto } from './dto/configure-access.dto'; +import { AccessLogQueryDto } from './dto/access-log-query.dto'; +import { KisiProvider } from './providers/kisi.provider'; +import { BrivoProvider } from './providers/brivo.provider'; +import { encryptApiKey, decryptApiKey, isEncrypted } from './crypto.util'; + +@Injectable() +export class DoorAccessService { + private readonly logger = new Logger(DoorAccessService.name); + + constructor( + @InjectRepository(AccessIntegration) + private readonly integrationRepo: Repository, + @InjectRepository(AccessCredential) + private readonly credentialRepo: Repository, + private readonly kisiProvider: KisiProvider, + private readonly brivoProvider: BrivoProvider, + private readonly configService: ConfigService, + ) {} + + // ─── helpers ──────────────────────────────────────────────────────────────── + + private encryptionKey(): string | null { + return this.configService.get('ACCESS_ENCRYPTION_KEY') ?? null; + } + + private encrypt(plaintext: string): string { + const key = this.encryptionKey(); + if (!key) { + this.logger.warn('ACCESS_ENCRYPTION_KEY not set — storing API key in plaintext'); + return plaintext; + } + return encryptApiKey(plaintext, key); + } + + private decrypt(stored: string): string { + const key = this.encryptionKey(); + if (!key || !isEncrypted(stored)) return stored; + return decryptApiKey(stored, key); + } + + private async getIntegration(): Promise { + return this.integrationRepo.findOne({ where: {}, order: { configuredAt: 'ASC' } }); + } + + // ─── admin ops ────────────────────────────────────────────────────────────── + + async configure(dto: ConfigureAccessDto): Promise { + const existing = await this.getIntegration(); + const record = existing ?? this.integrationRepo.create(); + + record.provider = dto.provider; + record.apiKey = this.encrypt(dto.apiKey); + record.isEnabled = dto.isEnabled ?? true; + record.meta = dto.doorGroupId ? { doorGroupId: dto.doorGroupId } : (record.meta ?? null); + record.configuredAt = new Date(); + + return this.integrationRepo.save(record); + } + + async getStatus(): Promise<{ + configured: boolean; + provider?: AccessProvider; + isEnabled: boolean; + }> { + const integration = await this.getIntegration(); + if (!integration) return { configured: false, isEnabled: false }; + return { + configured: true, + provider: integration.provider, + isEnabled: integration.isEnabled, + }; + } + + // ─── booking hooks ─────────────────────────────────────────────────────────── + + async grantAccess( + bookingId: string, + userId: string, + userEmail: string, + ): Promise { + const integration = await this.getIntegration(); + if (!integration?.isEnabled) return; + + const apiKey = this.decrypt(integration.apiKey); + const doorGroupId = integration.meta?.doorGroupId ?? ''; + + let externalCredentialId: string; + try { + if (integration.provider === AccessProvider.KISI) { + externalCredentialId = await this.kisiProvider.grantAccess( + apiKey, + userEmail, + doorGroupId, + ); + } else { + externalCredentialId = await this.brivoProvider.grantAccess( + apiKey, + userEmail, + doorGroupId, + ); + } + } catch (err) { + this.logger.error( + `Failed to grant ${integration.provider} access for booking ${bookingId}: ${err.message}`, + ); + return; + } + + const credential = this.credentialRepo.create({ + userId, + bookingId, + externalCredentialId, + provider: integration.provider, + isActive: true, + grantedAt: new Date(), + revokedAt: null, + }); + await this.credentialRepo.save(credential); + } + + async revokeAccess(bookingId: string): Promise { + const credential = await this.credentialRepo.findOne({ + where: { bookingId, isActive: true }, + }); + if (!credential) return; + + const integration = await this.getIntegration(); + if (!integration) return; + + const apiKey = this.decrypt(integration.apiKey); + + try { + if (credential.provider === AccessProvider.KISI) { + await this.kisiProvider.revokeAccess(apiKey, credential.externalCredentialId); + } else { + await this.brivoProvider.revokeAccess(apiKey, credential.externalCredentialId); + } + } catch (err) { + this.logger.error( + `Failed to revoke ${credential.provider} credential ${credential.externalCredentialId}: ${err.message}`, + ); + } + + credential.isActive = false; + credential.revokedAt = new Date(); + await this.credentialRepo.save(credential); + } + + // ─── admin logs ────────────────────────────────────────────────────────────── + + async getLogs(query: AccessLogQueryDto): Promise<{ + data: AccessCredential[]; + total: number; + page: number; + limit: number; + totalPages: number; + }> { + const { page = 1, limit = 20 } = query; + + const [data, total] = await this.credentialRepo + .createQueryBuilder('c') + .leftJoinAndSelect('c.user', 'user') + .select([ + 'c', + 'user.id', + 'user.firstname', + 'user.lastname', + 'user.email', + ]) + .orderBy('c.createdAt', 'DESC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit, totalPages: Math.ceil(total / limit) }; + } +} diff --git a/backend/src/integrations/access-control/dto/access-log-query.dto.ts b/backend/src/integrations/access-control/dto/access-log-query.dto.ts new file mode 100644 index 0000000..b7a205b --- /dev/null +++ b/backend/src/integrations/access-control/dto/access-log-query.dto.ts @@ -0,0 +1,17 @@ +import { IsInt, IsOptional } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class AccessLogQueryDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + limit?: number = 20; +} diff --git a/backend/src/integrations/access-control/dto/configure-access.dto.ts b/backend/src/integrations/access-control/dto/configure-access.dto.ts new file mode 100644 index 0000000..adde029 --- /dev/null +++ b/backend/src/integrations/access-control/dto/configure-access.dto.ts @@ -0,0 +1,30 @@ +import { + IsBoolean, + IsEnum, + IsOptional, + IsString, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AccessProvider } from '../enums/access-provider.enum'; + +export class ConfigureAccessDto { + @ApiProperty({ enum: AccessProvider }) + @IsEnum(AccessProvider) + provider: AccessProvider; + + @ApiProperty({ description: 'Provider API key or token' }) + @IsString() + apiKey: string; + + @ApiPropertyOptional({ description: 'Enable or disable the integration', default: true }) + @IsOptional() + @IsBoolean() + isEnabled?: boolean; + + @ApiPropertyOptional({ + description: 'Provider-specific door group / credential-template ID', + }) + @IsOptional() + @IsString() + doorGroupId?: string; +} diff --git a/backend/src/integrations/access-control/entities/access-credential.entity.ts b/backend/src/integrations/access-control/entities/access-credential.entity.ts new file mode 100644 index 0000000..d8fccd7 --- /dev/null +++ b/backend/src/integrations/access-control/entities/access-credential.entity.ts @@ -0,0 +1,52 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { User } from '../../../users/entities/user.entity'; +import { Booking } from '../../../bookings/entities/booking.entity'; +import { AccessProvider } from '../enums/access-provider.enum'; + +@Entity('access_credentials') +@Index(['userId']) +@Index(['bookingId']) +export class AccessCredential { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column({ type: 'uuid' }) + bookingId: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bookingId' }) + booking: Booking; + + @Column({ type: 'varchar' }) + externalCredentialId: string; + + @Column({ type: 'enum', enum: AccessProvider }) + provider: AccessProvider; + + @Column({ default: true }) + isActive: boolean; + + @Column({ type: 'timestamptz' }) + grantedAt: Date; + + @Column({ type: 'timestamptz', nullable: true }) + revokedAt: Date | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/integrations/access-control/entities/access-integration.entity.ts b/backend/src/integrations/access-control/entities/access-integration.entity.ts new file mode 100644 index 0000000..f5d67a9 --- /dev/null +++ b/backend/src/integrations/access-control/entities/access-integration.entity.ts @@ -0,0 +1,31 @@ +import { + Column, + Entity, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { AccessProvider } from '../enums/access-provider.enum'; + +/** + * Singleton row — only one record ever exists. + * Stores the active door-access provider and its encrypted API key. + */ +@Entity('access_integrations') +export class AccessIntegration { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'enum', enum: AccessProvider }) + provider: AccessProvider; + + @Column({ type: 'text' }) + apiKey: string; // AES-256-CBC encrypted; format: : + + @Column({ default: false }) + isEnabled: boolean; + + @Column({ type: 'jsonb', nullable: true }) + meta: { doorGroupId?: string } | null; + + @Column({ type: 'timestamptz' }) + configuredAt: Date; +} diff --git a/backend/src/integrations/access-control/enums/access-provider.enum.ts b/backend/src/integrations/access-control/enums/access-provider.enum.ts new file mode 100644 index 0000000..8261782 --- /dev/null +++ b/backend/src/integrations/access-control/enums/access-provider.enum.ts @@ -0,0 +1,4 @@ +export enum AccessProvider { + KISI = 'KISI', + BRIVO = 'BRIVO', +} diff --git a/backend/src/integrations/access-control/providers/brivo.provider.ts b/backend/src/integrations/access-control/providers/brivo.provider.ts new file mode 100644 index 0000000..3919a34 --- /dev/null +++ b/backend/src/integrations/access-control/providers/brivo.provider.ts @@ -0,0 +1,70 @@ +import { Injectable, Logger } from '@nestjs/common'; +import axios, { AxiosInstance } from 'axios'; + +/** + * Thin adapter for the Brivo Access API. + * + * Brivo flow: + * grant → find user by email → assign credential-template → returns credential ID + * revoke → DELETE /v1/api/credentials/{credentialId} + * + * The stored externalCredentialId is the Brivo credential ID (as string). + * The stored doorGroupId doubles as the Brivo credential-template ID. + * The apiKey is treated as a pre-obtained OAuth2 Bearer token or Brivo API key. + */ +@Injectable() +export class BrivoProvider { + private readonly logger = new Logger(BrivoProvider.name); + private readonly baseUrl = 'https://auth.brivo.com'; + + private client(apiKey: string): AxiosInstance { + return axios.create({ + baseURL: this.baseUrl, + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + } + + /** + * Grants access by assigning a credential to the user in Brivo. + * Returns the credential ID as the externalCredentialId. + */ + async grantAccess( + apiKey: string, + userEmail: string, + credentialTemplateId: string, + ): Promise { + const http = this.client(apiKey); + + // Step 1: find user by email + const { data: usersData } = await http.get('/v1/api/users', { + params: { filter: `email eq "${userEmail}"`, pageSize: 1 }, + }); + + const user = usersData?.data?.[0]; + if (!user) { + throw new Error(`Brivo: user not found for email ${userEmail}`); + } + + // Step 2: assign credential using the template + const { data: credData } = await http.post('/v1/api/credentials', { + credentialFormat: { id: Number(credentialTemplateId) }, + userId: user.id, + }); + + const credentialId = credData?.id ?? credData?.data?.id; + if (!credentialId) throw new Error('Brivo: credential response did not include an ID'); + + this.logger.log(`Brivo: granted credential ${credentialId} to ${userEmail}`); + return String(credentialId); + } + + async revokeAccess(apiKey: string, externalCredentialId: string): Promise { + const http = this.client(apiKey); + await http.delete(`/v1/api/credentials/${externalCredentialId}`); + this.logger.log(`Brivo: revoked credential ${externalCredentialId}`); + } +} diff --git a/backend/src/integrations/access-control/providers/kisi.provider.ts b/backend/src/integrations/access-control/providers/kisi.provider.ts new file mode 100644 index 0000000..705980c --- /dev/null +++ b/backend/src/integrations/access-control/providers/kisi.provider.ts @@ -0,0 +1,82 @@ +import { Injectable, Logger } from '@nestjs/common'; +import axios, { AxiosInstance } from 'axios'; + +/** + * Thin adapter for the Kisi REST API v3. + * + * Kisi flow: + * grant → invite user by email to org; returns grant ID + * revoke → DELETE /grants/{grantId} + * + * The stored externalCredentialId is the numeric Kisi grant ID (as string). + */ +@Injectable() +export class KisiProvider { + private readonly logger = new Logger(KisiProvider.name); + private readonly baseUrl = 'https://api.kisi.io'; + + private client(apiKey: string): AxiosInstance { + return axios.create({ + baseURL: this.baseUrl, + headers: { + Authorization: `KISI-LOGIN ${apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + } + + /** + * Grants access to the hub for the given user email. + * Returns the Kisi grant ID to use as externalCredentialId. + */ + async grantAccess( + apiKey: string, + userEmail: string, + doorGroupId: string, + ): Promise { + const http = this.client(apiKey); + + // Step 1: resolve Kisi user ID by email (members endpoint) + let kisiUserId: number | null = null; + try { + const { data } = await http.get('/organization_members', { + params: { query: userEmail, limit: 1 }, + }); + if (data?.organization_members?.length) { + kisiUserId = data.organization_members[0].user.id; + } + } catch { + // user may not exist yet — fall through to invite + } + + // Step 2: if user not found, create an invitation + if (!kisiUserId) { + const { data } = await http.post('/invitations', { + invitation: { email: userEmail }, + }); + kisiUserId = data?.invitation?.user?.id; + } + + if (!kisiUserId) { + throw new Error(`Kisi: could not resolve user ID for ${userEmail}`); + } + + // Step 3: grant access to the configured door group + const { data: grantData } = await http.post('/grants', { + grant: { user_id: kisiUserId, group_id: Number(doorGroupId) }, + }); + + const grantId = grantData?.grant?.id; + if (!grantId) throw new Error('Kisi: grant response did not include an ID'); + + this.logger.log(`Kisi: granted access to ${userEmail} (grant ${grantId})`); + return String(grantId); + } + + async revokeAccess(apiKey: string, externalCredentialId: string): Promise { + const http = this.client(apiKey); + await http.delete(`/grants/${externalCredentialId}`); + this.logger.log(`Kisi: revoked grant ${externalCredentialId}`); + } +} diff --git a/backend/src/nps/dto/nps-query.dto.ts b/backend/src/nps/dto/nps-query.dto.ts new file mode 100644 index 0000000..e7eec78 --- /dev/null +++ b/backend/src/nps/dto/nps-query.dto.ts @@ -0,0 +1,17 @@ +import { IsOptional, IsInt } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class NpsQueryDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + limit?: number = 20; +} diff --git a/backend/src/nps/dto/respond-nps.dto.ts b/backend/src/nps/dto/respond-nps.dto.ts new file mode 100644 index 0000000..24b130c --- /dev/null +++ b/backend/src/nps/dto/respond-nps.dto.ts @@ -0,0 +1,19 @@ +import { IsUUID, IsInt, Min, Max, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class RespondNpsDto { + @ApiProperty({ description: 'Booking ID the survey is for' }) + @IsUUID() + bookingId: string; + + @ApiProperty({ description: 'NPS score from 0 to 10', minimum: 0, maximum: 10 }) + @IsInt() + @Min(0) + @Max(10) + score: number; + + @ApiPropertyOptional({ description: 'Optional feedback comment' }) + @IsOptional() + @IsString() + comment?: string; +} diff --git a/backend/src/nps/entities/nps-survey-response.entity.ts b/backend/src/nps/entities/nps-survey-response.entity.ts new file mode 100644 index 0000000..6b6f4d4 --- /dev/null +++ b/backend/src/nps/entities/nps-survey-response.entity.ts @@ -0,0 +1,44 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Booking } from '../../bookings/entities/booking.entity'; + +@Entity('nps_survey_responses') +@Index(['userId']) +export class NpsSurveyResponse { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column({ type: 'uuid', unique: true }) + bookingId: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bookingId' }) + booking: Booking; + + @Column({ type: 'int', nullable: true }) + score: number | null; + + @Column({ type: 'text', nullable: true }) + comment: string | null; + + @Column({ type: 'timestamptz', nullable: true }) + submittedAt: Date | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/nps/nps-survey.processor.ts b/backend/src/nps/nps-survey.processor.ts new file mode 100644 index 0000000..a77da18 --- /dev/null +++ b/backend/src/nps/nps-survey.processor.ts @@ -0,0 +1,37 @@ +import { Process, Processor } from '@nestjs/bull'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bull'; +import { EmailService } from '../email/email.service'; + +export const NPS_QUEUE = 'nps-survey'; +export const NPS_SEND_JOB = 'send-survey'; + +export interface NpsSurveyJobData { + bookingId: string; + userEmail: string; + userName: string; + workspaceName: string; + bookingDate: string; + surveyUrl: string; +} + +@Processor(NPS_QUEUE) +export class NpsSurveyProcessor { + private readonly logger = new Logger(NpsSurveyProcessor.name); + + constructor(private readonly emailService: EmailService) {} + + @Process(NPS_SEND_JOB) + async handleSendSurvey(job: Job): Promise { + const { userEmail, userName, workspaceName, bookingDate, surveyUrl, bookingId } = + job.data; + + this.logger.log(`Sending NPS survey for booking ${bookingId} to ${userEmail}`); + + await this.emailService.sendNpsSurveyEmail(userEmail, userName, { + workspaceName, + bookingDate, + surveyUrl, + }); + } +} diff --git a/backend/src/nps/nps.controller.ts b/backend/src/nps/nps.controller.ts new file mode 100644 index 0000000..6863e0e --- /dev/null +++ b/backend/src/nps/nps.controller.ts @@ -0,0 +1,54 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { NpsService } from './nps.service'; +import { RespondNpsDto } from './dto/respond-nps.dto'; +import { NpsQueryDto } from './dto/nps-query.dto'; +import { GetCurrentUser } from '../auth/decorators/getCurrentUser.decorator'; +import { RolesGuard } from '../auth/guard/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorators'; +import { UserRole } from '../users/enums/userRoles.enum'; + +@ApiTags('nps') +@ApiBearerAuth() +@Controller('nps') +export class NpsController { + constructor(private readonly npsService: NpsService) {} + + @Post('respond') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Submit NPS survey response (member)' }) + async respond( + @GetCurrentUser('id') userId: string, + @Body() dto: RespondNpsDto, + ) { + const response = await this.npsService.respond(userId, dto); + return { message: 'Thank you for your feedback!', data: response }; + } + + @Get('summary') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN, UserRole.STAFF) + @ApiOperation({ summary: 'Get NPS summary stats (admin)' }) + async summary() { + const data = await this.npsService.getSummary(); + return { message: 'NPS summary retrieved', data }; + } + + @Get('responses') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN, UserRole.STAFF) + @ApiOperation({ summary: 'Get paginated NPS responses (admin)' }) + async responses(@Query() query: NpsQueryDto) { + const data = await this.npsService.getResponses(query); + return { message: 'NPS responses retrieved', data }; + } +} diff --git a/backend/src/nps/nps.module.ts b/backend/src/nps/nps.module.ts new file mode 100644 index 0000000..87b720c --- /dev/null +++ b/backend/src/nps/nps.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BullModule } from '@nestjs/bull'; +import { ConfigModule } from '@nestjs/config'; +import { NpsSurveyResponse } from './entities/nps-survey-response.entity'; +import { NpsService } from './nps.service'; +import { NpsController } from './nps.controller'; +import { NpsSurveyProcessor, NPS_QUEUE } from './nps-survey.processor'; +import { User } from '../users/entities/user.entity'; +import { Workspace } from '../workspaces/entities/workspace.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([NpsSurveyResponse, User, Workspace]), + BullModule.registerQueue({ name: NPS_QUEUE }), + ConfigModule, + ], + controllers: [NpsController], + providers: [NpsService, NpsSurveyProcessor], + exports: [NpsService], +}) +export class NpsModule {} diff --git a/backend/src/nps/nps.service.ts b/backend/src/nps/nps.service.ts new file mode 100644 index 0000000..42003da --- /dev/null +++ b/backend/src/nps/nps.service.ts @@ -0,0 +1,185 @@ +import { + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bull'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Queue } from 'bull'; +import { MoreThanOrEqual, Repository } from 'typeorm'; +import { NpsSurveyResponse } from './entities/nps-survey-response.entity'; +import { User } from '../users/entities/user.entity'; +import { Workspace } from '../workspaces/entities/workspace.entity'; +import { RespondNpsDto } from './dto/respond-nps.dto'; +import { NpsQueryDto } from './dto/nps-query.dto'; +import { NPS_QUEUE, NPS_SEND_JOB, NpsSurveyJobData } from './nps-survey.processor'; + +const TWO_HOURS_MS = 2 * 60 * 60 * 1000; +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +@Injectable() +export class NpsService { + private readonly logger = new Logger(NpsService.name); + + constructor( + @InjectRepository(NpsSurveyResponse) + private readonly npsRepo: Repository, + @InjectRepository(User) + private readonly usersRepo: Repository, + @InjectRepository(Workspace) + private readonly workspacesRepo: Repository, + @InjectQueue(NPS_QUEUE) + private readonly npsQueue: Queue, + private readonly configService: ConfigService, + ) {} + + async scheduleIfEligible( + userId: string, + bookingId: string, + workspaceId: string, + startDate: string, + ): Promise { + const thirtyDaysAgo = new Date(Date.now() - THIRTY_DAYS_MS); + + const [recentSurvey, existingForBooking, user, workspace] = await Promise.all([ + this.npsRepo.findOne({ + where: { userId, createdAt: MoreThanOrEqual(thirtyDaysAgo) }, + order: { createdAt: 'DESC' }, + }), + this.npsRepo.findOne({ where: { bookingId } }), + this.usersRepo.findOne({ where: { id: userId } }), + this.workspacesRepo.findOne({ where: { id: workspaceId } }), + ]); + + if (recentSurvey || existingForBooking || !user || !workspace) { + return; + } + + const record = this.npsRepo.create({ userId, bookingId, score: null, comment: null, submittedAt: null }); + await this.npsRepo.save(record); + + const frontendUrl = this.configService.get('FRONTEND_URL') || ''; + const surveyUrl = `${frontendUrl}/nps/${bookingId}`; + + const jobData: NpsSurveyJobData = { + bookingId, + userEmail: user.email, + userName: user.fullName, + workspaceName: workspace.name, + bookingDate: startDate, + surveyUrl, + }; + + await this.npsQueue.add(NPS_SEND_JOB, jobData, { delay: TWO_HOURS_MS }); + this.logger.log(`NPS survey queued for booking ${bookingId} (user ${userId})`); + } + + async respond(currentUserId: string, dto: RespondNpsDto): Promise { + const { bookingId, score, comment } = dto; + + const record = await this.npsRepo.findOne({ where: { bookingId } }); + if (!record) { + throw new NotFoundException('No survey found for this booking'); + } + if (record.userId !== currentUserId) { + throw new ForbiddenException('This survey does not belong to you'); + } + if (record.submittedAt !== null) { + throw new ConflictException('You have already responded to this survey'); + } + + record.score = score; + record.comment = comment ?? null; + record.submittedAt = new Date(); + return this.npsRepo.save(record); + } + + async getSummary(): Promise<{ + averageScore: number | null; + promoters: number; + passives: number; + detractors: number; + npsScore: number; + totalResponses: number; + recentComments: { score: number; comment: string; submittedAt: Date }[]; + }> { + const responses = await this.npsRepo + .createQueryBuilder('r') + .where('r.submittedAt IS NOT NULL') + .getMany(); + + const total = responses.length; + if (total === 0) { + return { + averageScore: null, + promoters: 0, + passives: 0, + detractors: 0, + npsScore: 0, + totalResponses: 0, + recentComments: [], + }; + } + + let scoreSum = 0; + let promoters = 0; + let passives = 0; + let detractors = 0; + + for (const r of responses) { + scoreSum += r.score; + if (r.score >= 9) promoters++; + else if (r.score >= 7) passives++; + else detractors++; + } + + const npsScore = Math.round(((promoters - detractors) / total) * 100); + + const recentComments = responses + .filter((r) => r.comment) + .sort((a, b) => b.submittedAt.getTime() - a.submittedAt.getTime()) + .slice(0, 10) + .map((r) => ({ score: r.score, comment: r.comment, submittedAt: r.submittedAt })); + + return { + averageScore: Math.round((scoreSum / total) * 10) / 10, + promoters, + passives, + detractors, + npsScore, + totalResponses: total, + recentComments, + }; + } + + async getResponses(query: NpsQueryDto): Promise<{ + data: NpsSurveyResponse[]; + total: number; + page: number; + limit: number; + totalPages: number; + }> { + const { page = 1, limit = 20 } = query; + + const [data, total] = await this.npsRepo + .createQueryBuilder('r') + .leftJoinAndSelect('r.user', 'user') + .select([ + 'r', + 'user.id', + 'user.firstname', + 'user.lastname', + 'user.email', + ]) + .where('r.submittedAt IS NOT NULL') + .orderBy('r.submittedAt', 'DESC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit, totalPages: Math.ceil(total / limit) }; + } +}