Add assets module with file upload and retrieval support
- Introduce `AssetsModule` with APIs for uploading and retrieving organization logos, building images, and floor plans. - Implement file validation for type and size (10MB max). - Add `resolveAssetUrl` utility for generating absolute asset URLs. - Enhance admin UI components to support image file uploads and previews. - Update Prisma schema to associate assets with organizations, buildings, and floors. - Modify existing update logic to clear references when external URLs are used.main
parent
b618c10cdd
commit
2bab9da7c8
|
|
@ -4,12 +4,23 @@
|
||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Fix ownership checks and improve delete booking button UI - Update `isOwner` logic to account for nested user objects in reservation data. - Enhance delete button styling for better visibility and usability.">
|
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add organization management with logo support - Implement organization settings page (`+page.svelte`) for updating organization name and logo. - Create backend APIs for retrieving and updating organization details with role-based access control. - Add `OrganizationsModule`, `OrganizationsService`, and DTOs for input validation in NestJS. - Update Prisma schema to include `logoUrl` field for organizations. - Enhance navigation to dynamically display organization logo and name.">
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/apps/api/apps/api/src/prisma/prisma.service.ts" beforeDir="false" />
|
<change beforePath="$PROJECT_DIR$/apps/api/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/package.json" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/apps/api/src/app.module.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/app.module.ts" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/apps/api/src/app.module.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/app.module.ts" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/api/src/organizations/dto/update-organization.dto.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/organizations/dto/update-organization.dto.ts" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/api/src/organizations/organizations.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/organizations/organizations.service.ts" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/api/src/spaces/dto/building-floor.dto.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/spaces/dto/building-floor.dto.ts" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/api/src/spaces/spaces.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/spaces/spaces.service.ts" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/api/client.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/api/client.ts" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/FloorplanViewer.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/FloorplanViewer.svelte" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/admin/BuildingEditorModal.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/admin/BuildingEditorModal.svelte" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/admin/FloorplanEditor.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/admin/FloorplanEditor.svelte" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/admin/organization/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/admin/organization/+page.svelte" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/+page.svelte" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" />
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
|
|
@ -131,7 +142,7 @@
|
||||||
<updated>1774643312599</updated>
|
<updated>1774643312599</updated>
|
||||||
<workItem from="1774643313611" duration="74000" />
|
<workItem from="1774643313611" duration="74000" />
|
||||||
<workItem from="1774643422240" duration="5278000" />
|
<workItem from="1774643422240" duration="5278000" />
|
||||||
<workItem from="1774706361502" duration="5853000" />
|
<workItem from="1774706361502" duration="7821000" />
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services - Set up SvelteKit for the web interface with Tailwind CSS. - Build the API using NestJS with Prisma ORM for database interaction. - Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. - Create shared data models for consistent typing across services. - Establish basic routing and layouts for web and API services.">
|
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services - Set up SvelteKit for the web interface with Tailwind CSS. - Build the API using NestJS with Prisma ORM for database interaction. - Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. - Create shared data models for consistent typing across services. - Establish basic routing and layouts for web and API services.">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
|
|
@ -237,7 +248,15 @@
|
||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1774711743510</updated>
|
<updated>1774711743510</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="14" />
|
<task id="LOCAL-00014" summary="Add organization management with logo support - Implement organization settings page (`+page.svelte`) for updating organization name and logo. - Create backend APIs for retrieving and updating organization details with role-based access control. - Add `OrganizationsModule`, `OrganizationsService`, and DTOs for input validation in NestJS. - Update Prisma schema to include `logoUrl` field for organizations. - Enhance navigation to dynamically display organization logo and name.">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1774712424451</created>
|
||||||
|
<option name="number" value="00014" />
|
||||||
|
<option name="presentableId" value="LOCAL-00014" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1774712424451</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="15" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
|
|
@ -258,6 +277,8 @@
|
||||||
<MESSAGE value="Add building and floor management with admin editor modal - Implement `BuildingEditorModal.svelte` for creating, editing, and deleting buildings and floors. - Add `CreateBuildingDto`, `UpdateBuildingDto`, `CreateFloorDto`, and `UpdateFloorDto` for validation. - Extend `SpacesService` and `SpacesController` with endpoints for building and floor CRUD operations. - Update Prisma schema to include building descriptions and image URLs, and floor names. - Enhance dashboard with building management functionality for admin users." />
|
<MESSAGE value="Add building and floor management with admin editor modal - Implement `BuildingEditorModal.svelte` for creating, editing, and deleting buildings and floors. - Add `CreateBuildingDto`, `UpdateBuildingDto`, `CreateFloorDto`, and `UpdateFloorDto` for validation. - Extend `SpacesService` and `SpacesController` with endpoints for building and floor CRUD operations. - Update Prisma schema to include building descriptions and image URLs, and floor names. - Enhance dashboard with building management functionality for admin users." />
|
||||||
<MESSAGE value="Enhance real-time space status updates and handling - Improve WebSocket connection reliability with updated transport options. - Introduce logic to prioritize real-time updates over initial data for live views. - Add logging for better debugging of WebSocket events and data propagation. - Update UI components (`SpaceCard`, `FloorplanViewer`) to avoid overwrites from stale data. - Refactor date handling for consistent behavior across components." />
|
<MESSAGE value="Enhance real-time space status updates and handling - Improve WebSocket connection reliability with updated transport options. - Introduce logic to prioritize real-time updates over initial data for live views. - Add logging for better debugging of WebSocket events and data propagation. - Update UI components (`SpaceCard`, `FloorplanViewer`) to avoid overwrites from stale data. - Refactor date handling for consistent behavior across components." />
|
||||||
<MESSAGE value="Fix ownership checks and improve delete booking button UI - Update `isOwner` logic to account for nested user objects in reservation data. - Enhance delete button styling for better visibility and usability." />
|
<MESSAGE value="Fix ownership checks and improve delete booking button UI - Update `isOwner` logic to account for nested user objects in reservation data. - Enhance delete button styling for better visibility and usability." />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="Fix ownership checks and improve delete booking button UI - Update `isOwner` logic to account for nested user objects in reservation data. - Enhance delete button styling for better visibility and usability." />
|
<MESSAGE value="Add organization management with logo support - Introduce `OrganizationsModule` with related logic in `AppModule`. - Update Prisma schema to include `logoUrl` field for organizations. - Enhance seeds to include default organization logo. - Update layout to dynamically display organization details, including logo and name. - Add UI support for organization-based navigation in admin routes." />
|
||||||
|
<MESSAGE value="Add organization management with logo support - Implement organization settings page (`+page.svelte`) for updating organization name and logo. - Create backend APIs for retrieving and updating organization details with role-based access control. - Add `OrganizationsModule`, `OrganizationsService`, and DTOs for input validation in NestJS. - Update Prisma schema to include `logoUrl` field for organizations. - Enhance navigation to dynamically display organization logo and name." />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="Add organization management with logo support - Implement organization settings page (`+page.svelte`) for updating organization name and logo. - Create backend APIs for retrieving and updating organization details with role-based access control. - Add `OrganizationsModule`, `OrganizationsService`, and DTOs for input validation in NestJS. - Update Prisma schema to include `logoUrl` field for organizations. - Enhance navigation to dynamically display organization logo and name." />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|
@ -50,6 +50,7 @@
|
||||||
"@nestjs/testing": "^10.4.22",
|
"@nestjs/testing": "^10.4.22",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^22.19.15",
|
"@types/node": "^22.19.15",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// apps/api/prisma/schema.prisma
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
@ -8,6 +7,96 @@ datasource db {
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Organization {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
logoUrl String?
|
||||||
|
logoId String? @unique
|
||||||
|
buildings Building[]
|
||||||
|
logo Asset? @relation(fields: [logoId], references: [id])
|
||||||
|
users User[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Building {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
organizationId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
description String?
|
||||||
|
imageUrl String?
|
||||||
|
imageId String? @unique
|
||||||
|
image Asset? @relation(fields: [imageId], references: [id])
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
|
floors Floor[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Floor {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
number Int
|
||||||
|
buildingId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
planImageUrl String?
|
||||||
|
name String?
|
||||||
|
planImageId String? @unique
|
||||||
|
building Building @relation(fields: [buildingId], references: [id])
|
||||||
|
planImage Asset? @relation(fields: [planImageId], references: [id])
|
||||||
|
spaces Space[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Asset {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
data Bytes
|
||||||
|
mimeType String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
building Building?
|
||||||
|
floor Floor?
|
||||||
|
organization Organization?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Space {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
type SpaceType
|
||||||
|
floorId String
|
||||||
|
x Float?
|
||||||
|
y Float?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
height Float?
|
||||||
|
width Float?
|
||||||
|
reservations Reservation[]
|
||||||
|
floor Floor @relation(fields: [floorId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
role Role @default(EMPLOYEE)
|
||||||
|
organizationId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
reservations Reservation[]
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Reservation {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
startTime DateTime
|
||||||
|
endTime DateTime
|
||||||
|
userId String
|
||||||
|
spaceId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
space Space @relation(fields: [spaceId], references: [id])
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
enum Role {
|
enum Role {
|
||||||
ADMIN
|
ADMIN
|
||||||
SUPERVISOR
|
SUPERVISOR
|
||||||
|
|
@ -19,76 +108,3 @@ enum SpaceType {
|
||||||
MEETING_ROOM
|
MEETING_ROOM
|
||||||
AMENITY
|
AMENITY
|
||||||
}
|
}
|
||||||
|
|
||||||
model Organization {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
name String
|
|
||||||
logoUrl String?
|
|
||||||
buildings Building[]
|
|
||||||
users User[]
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model Building {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
name String
|
|
||||||
description String? @db.Text
|
|
||||||
imageUrl String?
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id])
|
|
||||||
organizationId String
|
|
||||||
floors Floor[]
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model Floor {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
name String?
|
|
||||||
number Int
|
|
||||||
building Building @relation(fields: [buildingId], references: [id])
|
|
||||||
buildingId String
|
|
||||||
planImageUrl String?
|
|
||||||
spaces Space[]
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model Space {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
name String
|
|
||||||
type SpaceType
|
|
||||||
floor Floor @relation(fields: [floorId], references: [id])
|
|
||||||
floorId String
|
|
||||||
x Float? // Percentage-based for responsiveness (Stage 3)
|
|
||||||
y Float?
|
|
||||||
width Float? // Percentage-based for resizing (Stage 4)
|
|
||||||
height Float? // Percentage-based for resizing (Stage 4)
|
|
||||||
reservations Reservation[]
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
email String @unique
|
|
||||||
password String
|
|
||||||
role Role @default(EMPLOYEE)
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id])
|
|
||||||
organizationId String
|
|
||||||
reservations Reservation[]
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model Reservation {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
startTime DateTime
|
|
||||||
endTime DateTime
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
userId String
|
|
||||||
space Space @relation(fields: [spaceId], references: [id])
|
|
||||||
spaceId String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { EventsModule } from './events/events.module';
|
||||||
import { ReportsModule } from './reports/reports.module';
|
import { ReportsModule } from './reports/reports.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { OrganizationsModule } from './organizations/organizations.module';
|
import { OrganizationsModule } from './organizations/organizations.module';
|
||||||
|
import { AssetsModule } from './assets/assets.module';
|
||||||
import { LoggerMiddleware } from './common/middleware/logger.middleware';
|
import { LoggerMiddleware } from './common/middleware/logger.middleware';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|
@ -23,6 +24,7 @@ import { LoggerMiddleware } from './common/middleware/logger.middleware';
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
|
AssetsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Param,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
|
Res,
|
||||||
|
ParseFilePipe,
|
||||||
|
MaxFileSizeValidator,
|
||||||
|
FileTypeValidator,
|
||||||
|
UseGuards,
|
||||||
|
ForbiddenException,
|
||||||
|
Req,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { AssetsService } from './assets.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
|
||||||
|
@Controller('assets')
|
||||||
|
export class AssetsController {
|
||||||
|
constructor(private readonly assetsService: AssetsService) {}
|
||||||
|
|
||||||
|
@Post('organization/:id/logo')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(Role.ADMIN)
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
async uploadLogo(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Req() req,
|
||||||
|
@UploadedFile(
|
||||||
|
new ParseFilePipe({
|
||||||
|
validators: [
|
||||||
|
new MaxFileSizeValidator({ maxSize: 1024 * 1024 * 10 }), // 10MB
|
||||||
|
new FileTypeValidator({ fileType: 'image/(jpeg|png|svg+xml|webp)' }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
if (req.user.organizationId !== id) {
|
||||||
|
throw new ForbiddenException('You can only upload logo for your own organization');
|
||||||
|
}
|
||||||
|
return this.assetsService.uploadOrganizationLogo(id, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('building/:id/image')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(Role.ADMIN)
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
async uploadBuildingImage(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@UploadedFile(
|
||||||
|
new ParseFilePipe({
|
||||||
|
validators: [
|
||||||
|
new MaxFileSizeValidator({ maxSize: 1024 * 1024 * 10 }), // 10MB
|
||||||
|
new FileTypeValidator({ fileType: 'image/(jpeg|png|webp)' }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
// In a real app, check if user belongs to the organization that owns this building
|
||||||
|
return this.assetsService.uploadBuildingImage(id, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('floor/:id/plan')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(Role.ADMIN)
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
async uploadFloorPlan(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@UploadedFile(
|
||||||
|
new ParseFilePipe({
|
||||||
|
validators: [
|
||||||
|
new MaxFileSizeValidator({ maxSize: 1024 * 1024 * 10 }), // 10MB
|
||||||
|
new FileTypeValidator({ fileType: 'image/(jpeg|png|svg+xml|webp)' }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
// In a real app, check permissions
|
||||||
|
return this.assetsService.uploadFloorPlan(id, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async getAsset(@Param('id') id: string, @Res() res: Response) {
|
||||||
|
try {
|
||||||
|
const asset = await this.assetsService.getAsset(id);
|
||||||
|
res.set({
|
||||||
|
'Content-Type': asset.mimeType || 'image/png',
|
||||||
|
'Content-Length': asset.data.length,
|
||||||
|
'Cache-Control': 'public, max-age=31536000', // 1 year cache for unique assets
|
||||||
|
});
|
||||||
|
res.end(asset.data);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundException) {
|
||||||
|
res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
message: 'Asset not found',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('organization/:id/logo')
|
||||||
|
async getLogo(@Param('id') id: string, @Res() res: Response) {
|
||||||
|
try {
|
||||||
|
const asset = await this.assetsService.getOrganizationLogo(id);
|
||||||
|
res.set({
|
||||||
|
'Content-Type': asset.mimeType || 'image/png',
|
||||||
|
'Content-Length': asset.data.length,
|
||||||
|
'Cache-Control': 'no-cache', // Might change for the same entity ID
|
||||||
|
});
|
||||||
|
res.end(asset.data);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundException) {
|
||||||
|
res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
message: 'Logo not found',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('building/:id/image')
|
||||||
|
async getBuildingImage(@Param('id') id: string, @Res() res: Response) {
|
||||||
|
try {
|
||||||
|
const asset = await this.assetsService.getBuildingImage(id);
|
||||||
|
res.set({
|
||||||
|
'Content-Type': asset.mimeType || 'image/png',
|
||||||
|
'Content-Length': asset.data.length,
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
});
|
||||||
|
res.end(asset.data);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundException) {
|
||||||
|
res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
message: 'Building image not found',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('floor/:id/plan')
|
||||||
|
async getFloorPlan(@Param('id') id: string, @Res() res: Response) {
|
||||||
|
try {
|
||||||
|
const asset = await this.assetsService.getFloorPlan(id);
|
||||||
|
res.set({
|
||||||
|
'Content-Type': asset.mimeType || 'image/png',
|
||||||
|
'Content-Length': asset.data.length,
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
});
|
||||||
|
res.end(asset.data);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundException) {
|
||||||
|
res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
message: 'Floor plan not found',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AssetsService } from './assets.service';
|
||||||
|
import { AssetsController } from './assets.controller';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [AssetsService],
|
||||||
|
controllers: [AssetsController],
|
||||||
|
})
|
||||||
|
export class AssetsModule {}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssetsService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async uploadOrganizationLogo(id: string, file: Express.Multer.File) {
|
||||||
|
const asset = await this.prisma.asset.create({
|
||||||
|
data: {
|
||||||
|
data: Buffer.from(file.buffer),
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.prisma.organization.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
logoId: asset.id,
|
||||||
|
logoUrl: `/assets/${asset.id}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOrganizationLogo(id: string) {
|
||||||
|
return this.prisma.organization.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
logoId: null,
|
||||||
|
logoUrl: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadBuildingImage(id: string, file: Express.Multer.File) {
|
||||||
|
const asset = await this.prisma.asset.create({
|
||||||
|
data: {
|
||||||
|
data: Buffer.from(file.buffer),
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.prisma.building.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
imageId: asset.id,
|
||||||
|
imageUrl: `/assets/${asset.id}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFloorPlan(id: string, file: Express.Multer.File) {
|
||||||
|
const asset = await this.prisma.asset.create({
|
||||||
|
data: {
|
||||||
|
data: Buffer.from(file.buffer),
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.prisma.floor.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
planImageId: asset.id,
|
||||||
|
planImageUrl: `/assets/${asset.id}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrganizationLogo(id: string) {
|
||||||
|
const org = await this.prisma.organization.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { logo: true },
|
||||||
|
});
|
||||||
|
if (!org || !org.logo) {
|
||||||
|
throw new NotFoundException('Logo not found');
|
||||||
|
}
|
||||||
|
return org.logo;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAsset(id: string) {
|
||||||
|
const asset = await this.prisma.asset.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
if (!asset) {
|
||||||
|
throw new NotFoundException('Asset not found');
|
||||||
|
}
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBuildingImage(id: string) {
|
||||||
|
const building = await this.prisma.building.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { image: true },
|
||||||
|
});
|
||||||
|
if (!building || !building.image) {
|
||||||
|
throw new NotFoundException('Building image not found');
|
||||||
|
}
|
||||||
|
return building.image;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFloorPlan(id: string) {
|
||||||
|
const floor = await this.prisma.floor.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { planImage: true },
|
||||||
|
});
|
||||||
|
if (!floor || !floor.planImage) {
|
||||||
|
throw new NotFoundException('Floor plan not found');
|
||||||
|
}
|
||||||
|
return floor.planImage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ export class UpdateOrganizationDto {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUrl()
|
// @IsUrl()
|
||||||
|
@IsString()
|
||||||
logoUrl?: string | null;
|
logoUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,18 @@ export class OrganizationsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, updateOrganizationDto: UpdateOrganizationDto) {
|
async update(id: string, updateOrganizationDto: UpdateOrganizationDto) {
|
||||||
|
const data: any = { ...updateOrganizationDto };
|
||||||
|
|
||||||
|
// If logoUrl is being set to something that is not an internal asset, clear logoId
|
||||||
|
if (updateOrganizationDto.logoUrl !== undefined) {
|
||||||
|
if (updateOrganizationDto.logoUrl === null || updateOrganizationDto.logoUrl === '' || !updateOrganizationDto.logoUrl.startsWith('/assets/')) {
|
||||||
|
data.logoId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return this.prisma.organization.update({
|
return this.prisma.organization.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: updateOrganizationDto,
|
data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export class CreateBuildingDto {
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUrl()
|
// @IsUrl()
|
||||||
@IsString()
|
@IsString()
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -24,7 +24,7 @@ export class UpdateBuildingDto {
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUrl()
|
// @IsUrl()
|
||||||
@IsString()
|
@IsString()
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,16 @@ export class SpacesService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateBuilding(id: string, data: any) {
|
async updateBuilding(id: string, dto: any) {
|
||||||
|
const data: any = { ...dto };
|
||||||
|
|
||||||
|
// If imageUrl is being set to something that is not an internal asset, clear imageId
|
||||||
|
if (dto.imageUrl !== undefined) {
|
||||||
|
if (dto.imageUrl === null || dto.imageUrl === '' || !dto.imageUrl.startsWith('/assets/')) {
|
||||||
|
data.imageId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return this.prisma.building.update({
|
return this.prisma.building.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
|
|
@ -102,7 +111,16 @@ export class SpacesService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateFloor(id: string, data: any) {
|
async updateFloor(id: string, dto: any) {
|
||||||
|
const data: any = { ...dto };
|
||||||
|
|
||||||
|
// If planImageUrl is being set to something that is not an internal asset, clear planImageId
|
||||||
|
if (dto.planImageUrl !== undefined) {
|
||||||
|
if (dto.planImageUrl === null || dto.planImageUrl === '' || !dto.planImageUrl.startsWith('/assets/')) {
|
||||||
|
data.planImageId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return this.prisma.floor.update({
|
return this.prisma.floor.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
|
|
@ -116,9 +134,16 @@ export class SpacesService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateFloorPlan(floorId: string, planImageUrl: string) {
|
async updateFloorPlan(floorId: string, planImageUrl: string) {
|
||||||
|
const data: any = { planImageUrl };
|
||||||
|
|
||||||
|
// If planImageUrl is being set to something that is not an internal asset, clear planImageId
|
||||||
|
if (planImageUrl === null || planImageUrl === '' || !planImageUrl.startsWith('/assets/')) {
|
||||||
|
data.planImageId = null;
|
||||||
|
}
|
||||||
|
|
||||||
return this.prisma.floor.update({
|
return this.prisma.floor.update({
|
||||||
where: { id: floorId },
|
where: { id: floorId },
|
||||||
data: { planImageUrl },
|
data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,14 @@ export async function apiFetch<T>(
|
||||||
const fetchFn = options.fetch ?? fetch;
|
const fetchFn = options.fetch ?? fetch;
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(options.headers as Record<string, string> ?? {}),
|
...(options.headers as Record<string, string> ?? {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only set Content-Type to application/json if it's not already set and it's not a multipart request
|
||||||
|
if (!headers['Content-Type'] && !(options.body instanceof FormData)) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
// Automatically add Authorization header if token exists in localStorage (browser)
|
// Automatically add Authorization header if token exists in localStorage (browser)
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { io, Socket } from 'socket.io-client';
|
import { io, Socket } from 'socket.io-client';
|
||||||
import { Laptop, Bath, Coffee, Users, AlertCircle } from 'lucide-svelte';
|
import { Laptop, Bath, Coffee, Users, AlertCircle } from 'lucide-svelte';
|
||||||
import BookingModal from './BookingModal.svelte';
|
import BookingModal from './BookingModal.svelte';
|
||||||
|
import { resolveAssetUrl } from '$lib/utils/url';
|
||||||
|
|
||||||
export let floorId: string;
|
export let floorId: string;
|
||||||
export let spaces: any[] = [];
|
export let spaces: any[] = [];
|
||||||
|
|
@ -141,7 +142,7 @@
|
||||||
<div
|
<div
|
||||||
bind:this={container}
|
bind:this={container}
|
||||||
class="relative bg-white shadow-xl rounded-lg overflow-hidden border mx-auto floorplan-container"
|
class="relative bg-white shadow-xl rounded-lg overflow-hidden border mx-auto floorplan-container"
|
||||||
style="width: {containerWidth}px; height: {containerHeight}px; background-image: url({planImageUrl}); background-size: cover; background-repeat: no-repeat; background-position: center;"
|
style="width: {containerWidth}px; height: {containerHeight}px; background-image: url({resolveAssetUrl(planImageUrl)}); background-size: cover; background-repeat: no-repeat; background-position: center;"
|
||||||
>
|
>
|
||||||
{#each spaces as space (space.id)}
|
{#each spaces as space (space.id)}
|
||||||
{@const status = spaceStatus[space.id] || { isOccupied: false }}
|
{@const status = spaceStatus[space.id] || { isOccupied: false }}
|
||||||
|
|
@ -160,7 +161,7 @@
|
||||||
{#if status.isOccupied && space.type === 'DESK'}
|
{#if status.isOccupied && space.type === 'DESK'}
|
||||||
<div class="absolute -top-4 -left-4 w-6 h-6 rounded-full bg-white border border-yellow-500 overflow-hidden shadow-sm flex items-center justify-center">
|
<div class="absolute -top-4 -left-4 w-6 h-6 rounded-full bg-white border border-yellow-500 overflow-hidden shadow-sm flex items-center justify-center">
|
||||||
{#if status.reservation?.user?.profilePicture}
|
{#if status.reservation?.user?.profilePicture}
|
||||||
<img src={status.reservation.user.profilePicture} alt={status.reservation.user.email} class="w-full h-full object-cover" />
|
<img src={resolveAssetUrl(status.reservation.user.profilePicture)} alt={status.reservation.user.email} class="w-full h-full object-cover" />
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-[8px] font-bold text-yellow-700">
|
<span class="text-[8px] font-bold text-yellow-700">
|
||||||
{status.reservation?.user?.email?.charAt(0).toUpperCase()}
|
{status.reservation?.user?.email?.charAt(0).toUpperCase()}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { X, Plus, Trash2, Building2 } from 'lucide-svelte';
|
import { X, Plus, Trash2, Building2 } from 'lucide-svelte';
|
||||||
import { apiFetch } from '$lib/api/client';
|
import { apiFetch } from '$lib/api/client';
|
||||||
|
import { resolveAssetUrl } from '$lib/utils/url';
|
||||||
|
|
||||||
export let building: any = null; // if null, we are creating a new building
|
export let building: any = null; // if null, we are creating a new building
|
||||||
export let show = false;
|
export let show = false;
|
||||||
|
|
@ -11,11 +12,15 @@
|
||||||
let name = building?.name || '';
|
let name = building?.name || '';
|
||||||
let description = building?.description || '';
|
let description = building?.description || '';
|
||||||
let imageUrl = building?.imageUrl || '';
|
let imageUrl = building?.imageUrl || '';
|
||||||
|
let imageFile: File | null = null;
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
let floors = building?.floors ? JSON.parse(JSON.stringify(building.floors)) : [];
|
let floors = building?.floors ? JSON.parse(JSON.stringify(building.floors)) : [];
|
||||||
let loading = false;
|
let loading = false;
|
||||||
let error = '';
|
let error = '';
|
||||||
let showDangerZone = false;
|
let showDangerZone = false;
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (show && building) {
|
if (show && building) {
|
||||||
name = building.name;
|
name = building.name;
|
||||||
|
|
@ -26,22 +31,62 @@
|
||||||
name = '';
|
name = '';
|
||||||
description = '';
|
description = '';
|
||||||
imageUrl = '';
|
imageUrl = '';
|
||||||
|
imageFile = null;
|
||||||
floors = [];
|
floors = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleFileChange(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const file = target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
error = "File size exceeds 10MB limit";
|
||||||
|
target.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
imageFile = file;
|
||||||
|
// If a file is selected, clear the imageUrl to avoid ambiguity
|
||||||
|
imageUrl = "";
|
||||||
|
error = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUrlChange() {
|
||||||
|
// If the user starts typing a URL, clear the file selection
|
||||||
|
if (imageFile) {
|
||||||
|
imageFile = null;
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactive preview URL
|
||||||
|
$: previewUrl = imageFile ? URL.createObjectURL(imageFile) : resolveAssetUrl(imageUrl);
|
||||||
|
|
||||||
async function saveBuilding() {
|
async function saveBuilding() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
let savedBuilding;
|
let savedBuilding;
|
||||||
if (building) {
|
if (building) {
|
||||||
|
if (imageFile) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', imageFile);
|
||||||
|
const assetResponse = await apiFetch<any>(`/assets/building/${building.id}/image`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
imageUrl = assetResponse.imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
savedBuilding = await apiFetch(`/spaces/buildings/${building.id}`, {
|
savedBuilding = await apiFetch(`/spaces/buildings/${building.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name,
|
name,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
imageUrl: imageUrl || null
|
imageUrl: imageUrl || ""
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -60,14 +105,31 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
savedBuilding = await apiFetch('/spaces/buildings', {
|
savedBuilding = await apiFetch<any>('/spaces/buildings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name,
|
name,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
imageUrl: imageUrl || null
|
imageUrl: imageUrl || ""
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (imageFile) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', imageFile);
|
||||||
|
const assetResponse = await apiFetch<any>(`/assets/building/${savedBuilding.id}/image`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-update the building with the new image URL returned from asset upload
|
||||||
|
savedBuilding = await apiFetch(`/spaces/buildings/${savedBuilding.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({
|
||||||
|
imageUrl: assetResponse.imageUrl
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add floors if any were defined during creation
|
// Add floors if any were defined during creation
|
||||||
for (const floor of floors) {
|
for (const floor of floors) {
|
||||||
|
|
@ -78,6 +140,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
imageFile = null;
|
||||||
dispatch('save');
|
dispatch('save');
|
||||||
close();
|
close();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -173,11 +236,25 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Header Image URL</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Building Image</label>
|
||||||
|
<div class="mt-1 flex items-center space-x-4">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
bind:this={fileInput}
|
||||||
|
on:change={handleFileChange}
|
||||||
|
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Upload an image for this building (max 10MB).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Header Image URL (Optional)</label>
|
||||||
<div class="flex gap-4 items-center">
|
<div class="flex gap-4 items-center">
|
||||||
<div class="w-16 h-16 bg-gray-100 rounded-lg flex-shrink-0 flex items-center justify-center overflow-hidden">
|
<div class="w-16 h-16 bg-gray-100 rounded-lg flex-shrink-0 flex items-center justify-center overflow-hidden">
|
||||||
{#if imageUrl}
|
{#if previewUrl}
|
||||||
<img src={imageUrl} alt="Preview" class="w-full h-full object-cover" />
|
<img src={previewUrl} alt="Preview" class="w-full h-full object-cover" />
|
||||||
{:else}
|
{:else}
|
||||||
<Building2 class="text-gray-400" size={32} />
|
<Building2 class="text-gray-400" size={32} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -185,6 +262,7 @@
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={imageUrl}
|
bind:value={imageUrl}
|
||||||
|
on:input={handleUrlChange}
|
||||||
placeholder="https://images.unsplash.com/..."
|
placeholder="https://images.unsplash.com/..."
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import interact from 'interactjs';
|
import interact from 'interactjs';
|
||||||
import { Laptop, Bath, Coffee, Users, Save, Upload } from 'lucide-svelte';
|
import { Laptop, Bath, Coffee, Users, Save, Upload } from 'lucide-svelte';
|
||||||
import { apiFetch } from '$lib/api/client';
|
import { apiFetch } from '$lib/api/client';
|
||||||
|
import { resolveAssetUrl } from '$lib/utils/url';
|
||||||
|
|
||||||
export let floorId: string;
|
export let floorId: string;
|
||||||
export let initialSpaces: any[] = [];
|
export let initialSpaces: any[] = [];
|
||||||
|
|
@ -228,24 +229,29 @@
|
||||||
const file = target.files?.[0];
|
const file = target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
// In a real app, we would upload to S3/Cloudinary and get a URL
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
// For this stage, we'll use a data URL as a placeholder or mock the upload
|
alert("File size exceeds 10MB limit");
|
||||||
const reader = new FileReader();
|
target.value = "";
|
||||||
reader.onload = async (e) => {
|
return;
|
||||||
const url = e.target?.result as string;
|
}
|
||||||
planImageUrl = url;
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Upload binary asset
|
||||||
|
const updatedFloor = await apiFetch<any>(`/assets/floor/${floorId}/plan`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
// Save to backend
|
// Update local plan image URL (which should be the served asset URL)
|
||||||
try {
|
planImageUrl = updatedFloor.planImageUrl;
|
||||||
await apiFetch(`/spaces/floor/${floorId}/plan`, {
|
alert('Floorplan uploaded successfully!');
|
||||||
method: 'PATCH',
|
} catch (err: any) {
|
||||||
body: JSON.stringify({ planImageUrl: url })
|
console.error('Failed to upload floor plan', err);
|
||||||
});
|
alert(err.message || 'Failed to upload floor plan');
|
||||||
} catch (err) {
|
}
|
||||||
console.error('Failed to update floor plan', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveLayout() {
|
async function saveLayout() {
|
||||||
|
|
@ -350,7 +356,7 @@
|
||||||
<div
|
<div
|
||||||
bind:this={container}
|
bind:this={container}
|
||||||
class="relative bg-white shadow-xl rounded-lg overflow-hidden floorplan-container"
|
class="relative bg-white shadow-xl rounded-lg overflow-hidden floorplan-container"
|
||||||
style="width: {containerWidth}px; height: {containerHeight}px; background-image: url({planImageUrl}); background-size: cover; background-repeat: no-repeat; background-position: center;"
|
style="width: {containerWidth}px; height: {containerHeight}px; background-image: url({resolveAssetUrl(planImageUrl)}); background-size: cover; background-repeat: no-repeat; background-position: center;"
|
||||||
>
|
>
|
||||||
{#if !planImageUrl}
|
{#if !planImageUrl}
|
||||||
<div class="absolute inset-0 flex flex-col items-center justify-center text-gray-400 pointer-events-none">
|
<div class="absolute inset-0 flex flex-col items-center justify-center text-gray-400 pointer-events-none">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
export function resolveAssetUrl(url: string | null | undefined): string {
|
||||||
|
if (!url) return '';
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('blob:') || url.startsWith('data:')) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
// If it starts with /assets/, prepend API_BASE_URL
|
||||||
|
if (url.startsWith('/assets/')) {
|
||||||
|
// Check if it's already a full URL (though startsWith /assets/ implies relative)
|
||||||
|
return `${API_BASE_URL}${url}`;
|
||||||
|
}
|
||||||
|
// Otherwise, return as is (could be a legacy relative path or something else)
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { token, user } from "$lib/stores/auth";
|
import { token, user } from "$lib/stores/auth";
|
||||||
import { organization, loadOrganization } from "$lib/stores/organization";
|
import { organization, loadOrganization } from "$lib/stores/organization";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { resolveAssetUrl } from "$lib/utils/url";
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if ($user?.organizationId) {
|
if ($user?.organizationId) {
|
||||||
|
|
@ -19,7 +20,7 @@
|
||||||
<nav class="bg-white shadow-sm px-4 py-3 flex justify-between items-center">
|
<nav class="bg-white shadow-sm px-4 py-3 flex justify-between items-center">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
{#if $organization?.logoUrl}
|
{#if $organization?.logoUrl}
|
||||||
<img src={$organization.logoUrl} alt="Logo" class="w-8 h-8 rounded-lg object-contain" />
|
<img src={resolveAssetUrl($organization.logoUrl)} alt="Logo" class="w-8 h-8 rounded-lg object-contain" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
|
<div class="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
|
||||||
<span class="text-white font-bold">{$organization?.name?.charAt(0) || 'H'}</span>
|
<span class="text-white font-bold">{$organization?.name?.charAt(0) || 'H'}</span>
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,20 @@
|
||||||
import { user } from "$lib/stores/auth";
|
import { user } from "$lib/stores/auth";
|
||||||
import { apiFetch } from "$lib/api/client";
|
import { apiFetch } from "$lib/api/client";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { resolveAssetUrl } from "$lib/utils/url";
|
||||||
|
|
||||||
let name = "";
|
let name = "";
|
||||||
let logoUrl = "";
|
let logoUrl = "";
|
||||||
|
let logoFile: File | null = null;
|
||||||
let loading = false;
|
let loading = false;
|
||||||
let success = false;
|
let success = false;
|
||||||
let error = "";
|
let error = "";
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
|
||||||
|
let logoUrlInput: HTMLInputElement;
|
||||||
|
let logoFileInput: HTMLInputElement;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if ($user?.organizationId) {
|
if ($user?.organizationId) {
|
||||||
await loadOrganization($user.organizationId);
|
await loadOrganization($user.organizationId);
|
||||||
|
|
@ -25,12 +32,26 @@
|
||||||
success = false;
|
success = false;
|
||||||
error = "";
|
error = "";
|
||||||
try {
|
try {
|
||||||
|
let finalLogoUrl = logoUrl;
|
||||||
|
if (logoFile) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', logoFile);
|
||||||
|
const assetResponse = await apiFetch<any>(`/assets/organization/${$user?.organizationId}/logo`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
// Update local logoUrl with the actual URL from the backend
|
||||||
|
finalLogoUrl = assetResponse.logoUrl;
|
||||||
|
logoUrl = finalLogoUrl;
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await apiFetch<any>(`/organizations/${$user?.organizationId}`, {
|
const updated = await apiFetch<any>(`/organizations/${$user?.organizationId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ name, logoUrl: logoUrl || null }),
|
body: JSON.stringify({ name, logoUrl: finalLogoUrl || "" }),
|
||||||
});
|
});
|
||||||
organization.set(updated);
|
organization.set(updated);
|
||||||
success = true;
|
success = true;
|
||||||
|
logoFile = null;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error = e.message || "Failed to update organization";
|
error = e.message || "Failed to update organization";
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -38,8 +59,38 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleFileChange(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const file = target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
error = "File size exceeds 10MB limit";
|
||||||
|
target.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logoFile = file;
|
||||||
|
// If a file is selected, clear the logoUrl to avoid ambiguity
|
||||||
|
logoUrl = "";
|
||||||
|
error = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUrlChange() {
|
||||||
|
// If the user starts typing a URL, clear the file selection
|
||||||
|
if (logoFile) {
|
||||||
|
logoFile = null;
|
||||||
|
if (logoFileInput) {
|
||||||
|
logoFileInput.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactive preview URL
|
||||||
|
$: previewUrl = logoFile ? URL.createObjectURL(logoFile) : resolveAssetUrl(logoUrl);
|
||||||
|
|
||||||
function handleRemoveLogo() {
|
function handleRemoveLogo() {
|
||||||
logoUrl = "";
|
logoUrl = "";
|
||||||
|
logoFile = null;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -61,18 +112,34 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="logoUrl" class="block text-sm font-medium text-gray-700">Logo URL</label>
|
<label for="logoFile" class="block text-sm font-medium text-gray-700">Logo</label>
|
||||||
|
<div class="mt-1 flex items-center space-x-4">
|
||||||
|
<input
|
||||||
|
id="logoFile"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
bind:this={logoFileInput}
|
||||||
|
on:change={handleFileChange}
|
||||||
|
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Upload a logo for your organization (max 10MB).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="logoUrl" class="block text-sm font-medium text-gray-700">Logo URL (Optional)</label>
|
||||||
<input
|
<input
|
||||||
id="logoUrl"
|
id="logoUrl"
|
||||||
type="url"
|
type="url"
|
||||||
bind:value={logoUrl}
|
bind:value={logoUrl}
|
||||||
|
on:input={handleUrlChange}
|
||||||
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
placeholder="https://example.com/logo.png"
|
placeholder="https://example.com/logo.png"
|
||||||
/>
|
/>
|
||||||
<p class="mt-2 text-sm text-gray-500">Provide a URL for your organization's logo.</p>
|
<p class="mt-2 text-sm text-gray-500">Or provide a URL for your organization's logo.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if logoUrl}
|
{#if previewUrl}
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<p class="text-sm font-medium text-gray-700">Logo Preview:</p>
|
<p class="text-sm font-medium text-gray-700">Logo Preview:</p>
|
||||||
|
|
@ -85,7 +152,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-20 h-20 border rounded-lg flex items-center justify-center overflow-hidden bg-gray-50">
|
<div class="w-20 h-20 border rounded-lg flex items-center justify-center overflow-hidden bg-gray-50">
|
||||||
<img src={logoUrl} alt="Logo preview" class="max-w-full max-h-full object-contain" />
|
<img src={previewUrl} alt="Logo preview" class="max-w-full max-h-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import { apiFetch } from "$lib/api/client";
|
import { apiFetch } from "$lib/api/client";
|
||||||
import { Building2, ArrowRight, Plus, Edit2 } from "lucide-svelte";
|
import { Building2, ArrowRight, Plus, Edit2 } from "lucide-svelte";
|
||||||
import BuildingEditorModal from "$lib/components/admin/BuildingEditorModal.svelte";
|
import BuildingEditorModal from "$lib/components/admin/BuildingEditorModal.svelte";
|
||||||
|
import { resolveAssetUrl } from "$lib/utils/url";
|
||||||
|
|
||||||
let buildings = [];
|
let buildings = [];
|
||||||
let loading = true;
|
let loading = true;
|
||||||
|
|
@ -71,7 +72,7 @@
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow group relative">
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow group relative">
|
||||||
{#if building.imageUrl}
|
{#if building.imageUrl}
|
||||||
<div class="h-32 w-full overflow-hidden">
|
<div class="h-32 w-full overflow-hidden">
|
||||||
<img src={building.imageUrl} alt={building.name} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
|
<img src={resolveAssetUrl(building.imageUrl)} alt={building.name} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
"@nestjs/testing": "^10.4.22",
|
"@nestjs/testing": "^10.4.22",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^22.19.15",
|
"@types/node": "^22.19.15",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
|
|
@ -3519,6 +3520,15 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/multer": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/express": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.15",
|
"version": "22.19.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue