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
7 changes: 7 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npx tsc *)"
]
}
}
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>
14 changes: 14 additions & 0 deletions backend/src/hub-settings/dto/update-hub-settings.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
6 changes: 6 additions & 0 deletions backend/src/hub-settings/entities/hub-settings.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
86 changes: 74 additions & 12 deletions backend/src/hub-settings/hub-settings.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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();
}
Expand All @@ -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 } };
}
}
3 changes: 2 additions & 1 deletion backend/src/hub-settings/hub-settings.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
31 changes: 31 additions & 0 deletions backend/src/hub-settings/hub-settings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HubSettings>,
private readonly cloudinaryService: CloudinaryService,
) {}

/**
Expand Down Expand Up @@ -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<string> {
const result = await this.cloudinaryService.uploadImage(file, folder);
return (result as any).secure_url as string;
}
}
Loading
Loading