Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -148,6 +150,8 @@ import { TeamsModule } from './teams/teams.module';
LeadsModule,
CreditsModule,
TeamsModule,
NpsModule,
DoorAccessModule,
],
controllers: [AppController],
providers: [
Expand Down
4 changes: 4 additions & 0 deletions backend/src/bookings/bookings.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -30,6 +32,8 @@ import { PaystackProvider } from '../payments/providers/paystack.provider';
WorkspacesModule,
WaitlistModule,
ConfigModule,
NpsModule,
DoorAccessModule,
],
controllers: [BookingsController],
providers: [
Expand Down
6 changes: 6 additions & 0 deletions backend/src/bookings/providers/cancel-booking.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -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;
}
}
15 changes: 14 additions & 1 deletion backend/src/bookings/providers/complete-booking.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Booking>,
private readonly npsService: NpsService,
private readonly doorAccessService: DoorAccessService,
) {}

async complete(bookingId: string): Promise<Booking> {
Expand All @@ -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;
}
}
8 changes: 8 additions & 0 deletions backend/src/bookings/providers/confirm-booking.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -17,6 +18,7 @@ export class ConfirmBookingProvider {
private readonly bookingsRepository: Repository<Booking>,
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
private readonly doorAccessService: DoorAccessService,
) {}

async confirm(bookingId: string): Promise<Booking> {
Expand All @@ -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;
}
}
13 changes: 13 additions & 0 deletions backend/src/email/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,19 @@ export class EmailService {
]);
}

async sendNpsSurveyEmail(
email: string,
fullName: string,
data: {
workspaceName: string;
bookingDate: string;
surveyUrl: string;
},
): Promise<boolean> {
const html = this.compileTemplate('nps-survey', { fullName, ...data });
return this.send(email, 'How was your experience? — ManageHub', html);
}

async sendVisitorCheckInEmail(
host: User,
visitor: Visitor,
Expand Down
24 changes: 24 additions & 0 deletions backend/src/email/templates/nps-survey.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>How was your experience?</title></head>
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:0 auto;padding:20px">
<div style="background:#1a1a2e;padding:20px;border-radius:8px 8px 0 0">
<h1 style="color:#fff;margin:0;font-size:24px">ManageHub</h1>
</div>
<div style="background:#f9f9f9;padding:30px;border-radius:0 0 8px 8px;border:1px solid #e0e0e0">
<h2 style="color:#1a1a2e">How was your experience?</h2>
<p>Hi <strong>{{fullName}}</strong>,</p>
<p>Your recent booking at <strong>{{workspaceName}}</strong> on <strong>{{bookingDate}}</strong> is now complete. We'd love to hear how it went!</p>
<p>It takes less than a minute to share your feedback — and it helps us keep improving.</p>
<div style="text-align:center;margin:30px 0">
<a href="{{surveyUrl}}"
style="background:#1a1a2e;color:#fff;padding:14px 32px;border-radius:6px;text-decoration:none;font-size:16px;font-weight:bold;display:inline-block">
Rate Your Experience →
</a>
</div>
<p style="color:#666;font-size:14px">If the button doesn't work, paste this link into your browser:</p>
<p style="color:#666;font-size:12px;word-break:break-all">{{surveyUrl}}</p>
<p style="color:#999;font-size:12px;margin-top:30px">Thank you for choosing ManageHub.</p>
</div>
</body>
</html>
25 changes: 25 additions & 0 deletions backend/src/integrations/access-control/crypto.util.ts
Original file line number Diff line number Diff line change
@@ -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);
}
48 changes: 48 additions & 0 deletions backend/src/integrations/access-control/door-access.controller.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}
20 changes: 20 additions & 0 deletions backend/src/integrations/access-control/door-access.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading
Loading