diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..1ce6f63 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(npx tsc *)" + ] + } +} 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/hub-settings/dto/update-hub-settings.dto.ts b/backend/src/hub-settings/dto/update-hub-settings.dto.ts index 8794e22..874c02b 100644 --- a/backend/src/hub-settings/dto/update-hub-settings.dto.ts +++ b/backend/src/hub-settings/dto/update-hub-settings.dto.ts @@ -126,4 +126,18 @@ export class UpdateHubSettingsDto { @IsUrl() @IsOptional() logoUrl?: string; + + @ApiPropertyOptional({ + example: '#3b82f6', + description: 'Primary brand colour as a CSS hex string (e.g. #3b82f6)', + }) + @IsString() + @IsOptional() + @Length(4, 7) + primaryColorHex?: string; + + @ApiPropertyOptional({ example: 'https://cdn.example.com/favicon.ico' }) + @IsUrl() + @IsOptional() + faviconUrl?: string; } diff --git a/backend/src/hub-settings/entities/hub-settings.entity.ts b/backend/src/hub-settings/entities/hub-settings.entity.ts index 5652fa0..8f48486 100644 --- a/backend/src/hub-settings/entities/hub-settings.entity.ts +++ b/backend/src/hub-settings/entities/hub-settings.entity.ts @@ -51,6 +51,12 @@ export class HubSettings { @Column({ type: 'varchar', length: 500, nullable: true }) logoUrl: string; + @Column({ type: 'varchar', length: 7, nullable: true }) + primaryColorHex: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + faviconUrl: string; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/hub-settings/hub-settings.controller.ts b/backend/src/hub-settings/hub-settings.controller.ts index 738dfe5..56f90f4 100644 --- a/backend/src/hub-settings/hub-settings.controller.ts +++ b/backend/src/hub-settings/hub-settings.controller.ts @@ -1,6 +1,18 @@ -import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Patch, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, + ApiBody, + ApiConsumes, ApiOperation, ApiResponse, ApiTags, @@ -20,10 +32,7 @@ export class HubSettingsController { @Get() @Public() @ApiOperation({ summary: 'Get current hub configuration (public)' }) - @ApiResponse({ - status: 200, - description: 'Hub settings retrieved successfully.', - }) + @ApiResponse({ status: 200, description: 'Hub settings retrieved successfully.' }) getSettings() { return this.hubSettingsService.getSettings(); } @@ -32,14 +41,67 @@ export class HubSettingsController { @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) @UseGuards(RolesGuard) @ApiBearerAuth() - @ApiOperation({ - summary: 'Update hub configuration (Admin / Super-admin only)', - }) - @ApiResponse({ - status: 200, - description: 'Hub settings updated successfully.', - }) + @ApiOperation({ summary: 'Update hub configuration (Admin / Super-admin only)' }) + @ApiResponse({ status: 200, description: 'Hub settings updated successfully.' }) updateSettings(@Body() updateHubSettingsDto: UpdateHubSettingsDto) { return this.hubSettingsService.updateSettings(updateHubSettingsDto); } + + // ── Branding sub-routes ──────────────────────────────────────────────── + + @Get('branding') + @Public() + @ApiOperation({ summary: 'Get hub branding config (public)' }) + @ApiResponse({ status: 200, description: 'Branding config retrieved.' }) + getBranding() { + return this.hubSettingsService.getBranding(); + } + + @Patch('branding') + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + @UseGuards(RolesGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Update hub branding (Admin only)' }) + @ApiResponse({ status: 200, description: 'Branding updated.' }) + updateBranding(@Body() dto: UpdateHubSettingsDto) { + return this.hubSettingsService.updateSettings(dto); + } + + @Post('branding/upload-logo') + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + @UseGuards(RolesGuard) + @ApiBearerAuth() + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { file: { type: 'string', format: 'binary' } }, + }, + }) + @UseInterceptors(FileInterceptor('file')) + @ApiOperation({ summary: 'Upload hub logo (Admin only)' }) + async uploadLogo(@UploadedFile() file: Express.Multer.File) { + const url = await this.hubSettingsService.uploadBrandAsset(file, 'hub-logos'); + await this.hubSettingsService.updateSettings({ logoUrl: url }); + return { success: true, data: { logoUrl: url } }; + } + + @Post('branding/upload-favicon') + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + @UseGuards(RolesGuard) + @ApiBearerAuth() + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { file: { type: 'string', format: 'binary' } }, + }, + }) + @UseInterceptors(FileInterceptor('file')) + @ApiOperation({ summary: 'Upload hub favicon (Admin only)' }) + async uploadFavicon(@UploadedFile() file: Express.Multer.File) { + const url = await this.hubSettingsService.uploadBrandAsset(file, 'hub-favicons'); + await this.hubSettingsService.updateSettings({ faviconUrl: url }); + return { success: true, data: { faviconUrl: url } }; + } } diff --git a/backend/src/hub-settings/hub-settings.module.ts b/backend/src/hub-settings/hub-settings.module.ts index 7cb8235..ece7d9d 100644 --- a/backend/src/hub-settings/hub-settings.module.ts +++ b/backend/src/hub-settings/hub-settings.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { HubSettings } from './entities/hub-settings.entity'; import { HubSettingsService } from './hub-settings.service'; import { HubSettingsController } from './hub-settings.controller'; +import { CloudinaryModule } from '../cloudinary/cloudinary.module'; @Module({ - imports: [TypeOrmModule.forFeature([HubSettings])], + imports: [TypeOrmModule.forFeature([HubSettings]), CloudinaryModule], controllers: [HubSettingsController], providers: [HubSettingsService], exports: [HubSettingsService], diff --git a/backend/src/hub-settings/hub-settings.service.ts b/backend/src/hub-settings/hub-settings.service.ts index d414e22..247a974 100644 --- a/backend/src/hub-settings/hub-settings.service.ts +++ b/backend/src/hub-settings/hub-settings.service.ts @@ -3,12 +3,14 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { HubSettings } from './entities/hub-settings.entity'; import { UpdateHubSettingsDto } from './dto/update-hub-settings.dto'; +import { CloudinaryService } from '../cloudinary/cloudinary.service'; @Injectable() export class HubSettingsService { constructor( @InjectRepository(HubSettings) private readonly hubSettingsRepository: Repository, + private readonly cloudinaryService: CloudinaryService, ) {} /** @@ -49,4 +51,33 @@ export class HubSettingsService { return this.hubSettingsRepository.save(settings); } + + /** + * Returns only the fields required for frontend white-labeling. + */ + async getBranding(): Promise<{ + hubName: string; + logoUrl: string | null; + primaryColorHex: string | null; + faviconUrl: string | null; + }> { + const settings = await this.getSettings(); + return { + hubName: settings.hubName, + logoUrl: settings.logoUrl ?? null, + primaryColorHex: settings.primaryColorHex ?? null, + faviconUrl: settings.faviconUrl ?? null, + }; + } + + /** + * Uploads a brand asset (logo or favicon) to Cloudinary and returns the URL. + */ + async uploadBrandAsset( + file: Express.Multer.File, + folder: string, + ): Promise { + const result = await this.cloudinaryService.uploadImage(file, folder); + return (result as any).secure_url as string; + } } 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) }; + } +} diff --git a/frontend/app/admin/analytics/page.tsx b/frontend/app/admin/analytics/page.tsx index 5d176be..7b1eb99 100644 --- a/frontend/app/admin/analytics/page.tsx +++ b/frontend/app/admin/analytics/page.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import DashboardLayout from "@/components/dashboard/DashboardLayout"; import { useGetAdminAnalytics } from "@/lib/react-query/hooks/admin/analytics/useGetAdminAnalytics"; +import { useGetNpsAnalytics } from "@/lib/react-query/hooks/nps/useGetNpsAnalytics"; import { TrendingUp, BookOpen, @@ -11,7 +12,9 @@ import { MonitorCheck, RefreshCw, Trophy, + Star, } from "lucide-react"; +import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts"; function formatNaira(kobo: number): string { return new Intl.NumberFormat("en-NG", { @@ -60,7 +63,10 @@ export default function AdminAnalyticsPage() { appliedTo ); + const { data: npsData, isLoading: npsLoading } = useGetNpsAnalytics(); + const analytics = data?.data; + const nps = npsData?.data; const applyFilter = () => { setAppliedFrom(from || undefined); @@ -298,6 +304,128 @@ export default function AdminAnalyticsPage() { )} + {/* NPS */} +
+

+ Net Promoter Score +

+ {npsLoading ? ( +
+ ) : !nps || nps.totalResponses === 0 ? ( +
+

No NPS responses yet.

+
+ ) : ( +
+ {/* Score + donut */} +
+
+
+ + NPS +
+

= 50 + ? "text-emerald-600" + : nps.score >= 0 + ? "text-amber-500" + : "text-red-500" + }`} + > + {nps.score > 0 ? `+${nps.score}` : nps.score} +

+

+ {nps.totalResponses} response{nps.totalResponses !== 1 ? "s" : ""} +

+
+ +
+ + + + + + + + `${v}%`} + contentStyle={{ + borderRadius: 8, + border: "1px solid #e5e7eb", + fontSize: 12, + }} + /> + + +
+ +
+
+ + + Promoters {nps.promoterPct}% + +
+
+ + + Passives {nps.passivePct}% + +
+
+ + + Detractors {nps.detractorPct}% + +
+
+
+ + {/* Recent comments */} + {nps.recentComments.length > 0 && ( +
+

+ Recent comments +

+
+ {nps.recentComments.slice(0, 5).map((c, i) => ( +
+ = 9 + ? "bg-emerald-500" + : c.score >= 7 + ? "bg-amber-400" + : "bg-red-500" + }`} + > + {c.score} + +

+ {c.comment} +

+
+ ))} +
+
+ )} +
+ )} +
+ {/* Top workspaces + members */}
{analytics.topWorkspaces.length > 0 && ( diff --git a/frontend/app/admin/settings/branding/page.tsx b/frontend/app/admin/settings/branding/page.tsx new file mode 100644 index 0000000..e0df85c --- /dev/null +++ b/frontend/app/admin/settings/branding/page.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Image from "next/image"; +import DashboardLayout from "@/components/dashboard/DashboardLayout"; +import { useGetBranding } from "@/lib/react-query/hooks/branding/useGetBranding"; +import { useUpdateBranding } from "@/lib/react-query/hooks/branding/useUpdateBranding"; +import { + useUploadLogo, + useUploadFavicon, +} from "@/lib/react-query/hooks/branding/useUploadBrandAsset"; +import { Upload, RefreshCw } from "lucide-react"; + +export default function BrandingSettingsPage() { + const { data: current, isLoading } = useGetBranding(); + + const [hubName, setHubName] = useState(""); + const [primaryColorHex, setPrimaryColorHex] = useState("#111827"); + const [logoUrl, setLogoUrl] = useState(null); + const [faviconUrl, setFaviconUrl] = useState(null); + + const logoInputRef = useRef(null); + const faviconInputRef = useRef(null); + + // Populate form from fetched branding + useEffect(() => { + if (!current) return; + setHubName(current.hubName ?? ""); + setPrimaryColorHex(current.primaryColorHex ?? "#111827"); + setLogoUrl(current.logoUrl ?? null); + setFaviconUrl(current.faviconUrl ?? null); + }, [current]); + + const { mutate: saveBranding, isPending: saving } = useUpdateBranding(); + const { mutate: uploadLogo, isPending: uploadingLogo } = useUploadLogo( + (url) => setLogoUrl(url) + ); + const { mutate: uploadFavicon, isPending: uploadingFavicon } = + useUploadFavicon((url) => setFaviconUrl(url)); + + function handleSave() { + saveBranding({ + hubName: hubName.trim() || undefined, + primaryColorHex: primaryColorHex || undefined, + logoUrl: logoUrl ?? undefined, + faviconUrl: faviconUrl ?? undefined, + }); + // Live-preview the colour + document.documentElement.style.setProperty( + "--color-primary", + primaryColorHex + ); + } + + return ( + +
+

Branding

+

+ Customise how your hub appears to members. +

+
+ + {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : ( +
+ {/* Hub name */} +
+ + setHubName(e.target.value)} + placeholder="ManageHub" + className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-200" + /> +
+ + {/* Primary colour */} +
+ +
+ setPrimaryColorHex(e.target.value)} + className="w-10 h-10 rounded-lg border border-gray-200 cursor-pointer p-0.5" + /> + { + const v = e.target.value; + if (/^#[0-9a-fA-F]{0,6}$/.test(v)) setPrimaryColorHex(v); + }} + maxLength={7} + className="w-32 text-sm border border-gray-200 rounded-lg px-3 py-2 font-mono focus:outline-none focus:ring-2 focus:ring-gray-200" + /> + + Used for active nav items, buttons, and accents. + +
+
+ + {/* Logo */} +
+ +
+ {logoUrl ? ( + Hub logo + ) : ( +
+ No logo +
+ )} +
+ +

+ PNG, SVG or JPG — recommended 200×200 px +

+
+
+ { + const file = e.target.files?.[0]; + if (file) uploadLogo(file); + e.target.value = ""; + }} + /> +
+ + {/* Favicon */} +
+ +
+ {faviconUrl ? ( + Favicon + ) : ( +
+ — +
+ )} +
+ +

+ ICO, PNG or SVG — 32×32 px +

+
+
+ { + const file = e.target.files?.[0]; + if (file) uploadFavicon(file); + e.target.value = ""; + }} + /> +
+ + {/* Save */} +
+ +
+
+ )} + + ); +} diff --git a/frontend/app/dashboard/DashboardContent.tsx b/frontend/app/dashboard/DashboardContent.tsx index aed1415..d1ec323 100644 --- a/frontend/app/dashboard/DashboardContent.tsx +++ b/frontend/app/dashboard/DashboardContent.tsx @@ -11,6 +11,7 @@ import AnalyticsChart from "@/components/dashboard/AnalyticsChart"; import AdminOverview from "@/components/dashboard/AdminOverview"; import AdminUserTable from "@/components/dashboard/AdminUserTable"; import MemberStatsCards from "@/components/dashboard/MemberStatsCards"; +import NpsBanner from "@/components/nps/NpsBanner"; interface Stats { totalMembers: number; @@ -132,6 +133,9 @@ export default function DashboardContent() {
) : (
+ {/* NPS survey prompt — shown to members only when a pending survey exists */} + {!isAdmin && } + {/* Stats cards */} {isAdmin ? ( diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 7994dad..e69f6cb 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,5 +1,10 @@ @import "tailwindcss"; +/* White-label primary colour — overridden at runtime by BrandingProvider */ +:root { + --color-primary: #111827; +} + /* Scrollbar — WebKit */ ::-webkit-scrollbar { width: 8px; diff --git a/frontend/components/dashboard/DashboardSidebar.tsx b/frontend/components/dashboard/DashboardSidebar.tsx index 8e73ab3..d3ac567 100644 --- a/frontend/components/dashboard/DashboardSidebar.tsx +++ b/frontend/components/dashboard/DashboardSidebar.tsx @@ -1,16 +1,18 @@ "use client"; +import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { Building2, LayoutDashboard, User, Settings, LogOut, Users, Mail, Menu, X, BookOpen, FileText, BriefcaseBusiness, LogIn, Bell, BarChart3, CreditCard, Boxes, Calendar, MapPin, Wrench, Lock, - MessageSquare, + MessageSquare, Palette, } from "lucide-react"; import { useState } from "react"; import { useAuthState, useAuthActions } from "@/lib/store/authStore"; import NotificationBell from "@/components/notifications/NotificationBell"; +import { useBranding } from "@/lib/branding/BrandingContext"; const navItems = [ { label: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, @@ -29,18 +31,19 @@ const navItems = [ ]; const adminItems = [ - { label: "Analytics", href: "/admin/analytics", icon: BarChart3 }, - { label: "Workspaces", href: "/admin/workspaces", icon: Building2 }, - { label: "All Bookings", href: "/admin/bookings", icon: BookOpen }, - { label: "Payments", href: "/admin/payments", icon: CreditCard }, - { label: "Members", href: "/admin/members", icon: Users }, - { label: "Invoices", href: "/admin/invoices", icon: FileText }, - { label: "Newsletter", href: "/dashboard?tab=newsletter", icon: Mail }, - { label: "Leads", href: "/admin/leads", icon: Users }, - { label: "Contracts", href: "/admin/contracts", icon: FileText }, - { label: "Reports", href: "/admin/reports", icon: BarChart3 }, - { label: "Staff Schedule", href: "/admin/staff", icon: Calendar }, - { label: "Facilities", href: "/admin/facilities", icon: MapPin }, + { label: "Analytics", href: "/admin/analytics", icon: BarChart3 }, + { label: "Workspaces", href: "/admin/workspaces", icon: Building2 }, + { label: "All Bookings", href: "/admin/bookings", icon: BookOpen }, + { label: "Payments", href: "/admin/payments", icon: CreditCard }, + { label: "Members", href: "/admin/members", icon: Users }, + { label: "Invoices", href: "/admin/invoices", icon: FileText }, + { label: "Newsletter", href: "/dashboard?tab=newsletter", icon: Mail }, + { label: "Leads", href: "/admin/leads", icon: Users }, + { label: "Contracts", href: "/admin/contracts", icon: FileText }, + { label: "Reports", href: "/admin/reports", icon: BarChart3 }, + { label: "Staff Schedule", href: "/admin/staff", icon: Calendar }, + { label: "Facilities", href: "/admin/facilities", icon: MapPin }, + { label: "Branding", href: "/admin/settings/branding", icon: Palette }, ]; export default function DashboardSidebar() { @@ -49,6 +52,7 @@ export default function DashboardSidebar() { const { logout } = useAuthActions(); const [mobileOpen, setMobileOpen] = useState(false); const isAdmin = user?.role === "admin"; + const { hubName, logoUrl } = useBranding(); const handleLogout = () => { logout(); @@ -60,10 +64,20 @@ export default function DashboardSidebar() { {/* Brand */}
- - - - ManageHub + {logoUrl ? ( + {hubName} + ) : ( + + + + )} + {hubName}
diff --git a/frontend/components/nps/NpsBanner.tsx b/frontend/components/nps/NpsBanner.tsx new file mode 100644 index 0000000..144f71b --- /dev/null +++ b/frontend/components/nps/NpsBanner.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { X, MessageSquare } from "lucide-react"; +import { useGetPendingNpsSurvey } from "@/lib/react-query/hooks/nps/useGetPendingNpsSurvey"; +import { useSubmitNpsResponse } from "@/lib/react-query/hooks/nps/useSubmitNpsResponse"; + +const DISMISS_KEY = "nps_dismissed_until"; + +function isDismissed(): boolean { + if (typeof window === "undefined") return false; + const until = localStorage.getItem(DISMISS_KEY); + if (!until) return false; + return Date.now() < Number(until); +} + +function dismissFor24h() { + localStorage.setItem(DISMISS_KEY, String(Date.now() + 24 * 60 * 60 * 1000)); +} + +export default function NpsBanner() { + const { data, isLoading } = useGetPendingNpsSurvey(); + const [dismissed, setDismissed] = useState(true); + const [selectedScore, setSelectedScore] = useState(null); + const [comment, setComment] = useState(""); + const [submitted, setSubmitted] = useState(false); + + const survey = data?.data; + + const { mutate: submitResponse, isPending } = useSubmitNpsResponse(() => { + setSubmitted(true); + }); + + useEffect(() => { + setDismissed(isDismissed()); + }, []); + + if (isLoading || dismissed || !survey || submitted) return null; + + function handleDismiss() { + dismissFor24h(); + setDismissed(true); + } + + function handleSubmit() { + if (selectedScore === null || !survey) return; + submitResponse({ + surveyId: survey.surveyId, + score: selectedScore, + comment: comment.trim() || undefined, + }); + } + + return ( +
+
+
+ +

+ How was your experience at{" "} + {survey.workspaceName}? +

+
+ +
+ + {/* Score buttons */} +
+
+ {Array.from({ length: 11 }, (_, i) => ( + + ))} +
+
+ Not at all likely + Extremely likely +
+
+ + {/* Comment textarea — shows once a score is picked */} + {selectedScore !== null && ( +
+