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.
main
Pau Costa Ferrer 2026-03-28 16:40:24 +01:00
parent 56c52bfcc7
commit b618c10cdd
13 changed files with 264 additions and 24 deletions

View File

@ -4,13 +4,12 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add building and floor management with admin editor modal&#10;&#10;- Implement `BuildingEditorModal.svelte` for creating, editing, and deleting buildings and floors.&#10;- Add `CreateBuildingDto`, `UpdateBuildingDto`, `CreateFloorDto`, and `UpdateFloorDto` for validation.&#10;- Extend `SpacesService` and `SpacesController` with endpoints for building and floor CRUD operations.&#10;- Update Prisma schema to include building descriptions and image URLs, and floor names.&#10;- Enhance dashboard with building management functionality for admin users.">
<change beforePath="$PROJECT_DIR$/apps/api/src/events/events.gateway.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/events/events.gateway.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/reservations/reservations.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/reservations/reservations.service.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/BookingModal.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/BookingModal.svelte" 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/SpaceCard.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/SpaceCard.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/floor/[id]/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/floor/[id]/+page.svelte" afterDir="false" />
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Fix ownership checks and improve delete booking button UI&#10;&#10;- Update `isOwner` logic to account for nested user objects in reservation data.&#10;- Enhance delete button styling for better visibility and usability.">
<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/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/web/src/routes/+layout.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -132,7 +131,7 @@
<updated>1774643312599</updated>
<workItem from="1774643313611" duration="74000" />
<workItem from="1774643422240" duration="5278000" />
<workItem from="1774706361502" duration="5157000" />
<workItem from="1774706361502" duration="5853000" />
</task>
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services &#10;&#10;- Set up SvelteKit for the web interface with Tailwind CSS. &#10;- Build the API using NestJS with Prisma ORM for database interaction. &#10;- Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. &#10;- Create shared data models for consistent typing across services. &#10;- Establish basic routing and layouts for web and API services.">
<option name="closed" value="true" />
@ -222,7 +221,23 @@
<option name="project" value="LOCAL" />
<updated>1774711356754</updated>
</task>
<option name="localTasksCounter" value="12" />
<task id="LOCAL-00012" summary="Enhance real-time space status updates and handling&#10;&#10;- Improve WebSocket connection reliability with updated transport options.&#10;- Introduce logic to prioritize real-time updates over initial data for live views.&#10;- Add logging for better debugging of WebSocket events and data propagation.&#10;- Update UI components (`SpaceCard`, `FloorplanViewer`) to avoid overwrites from stale data.&#10;- Refactor date handling for consistent behavior across components.">
<option name="closed" value="true" />
<created>1774711643921</created>
<option name="number" value="00012" />
<option name="presentableId" value="LOCAL-00012" />
<option name="project" value="LOCAL" />
<updated>1774711643921</updated>
</task>
<task id="LOCAL-00013" summary="Fix ownership checks and improve delete booking button UI&#10;&#10;- Update `isOwner` logic to account for nested user objects in reservation data.&#10;- Enhance delete button styling for better visibility and usability.">
<option name="closed" value="true" />
<created>1774711743510</created>
<option name="number" value="00013" />
<option name="presentableId" value="LOCAL-00013" />
<option name="project" value="LOCAL" />
<updated>1774711743510</updated>
</task>
<option name="localTasksCounter" value="14" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@ -241,6 +256,8 @@
<MESSAGE value="Add user management and registration features with invite-based onboarding&#10;&#10;- Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation. Update `UsersController` and `UsersService` to support user role updates, password changes, and invite token generation.&#10;- Create DTOs (`RegisterDto`, `UpdateUserDto`) for input validation in user management endpoints.&#10;- Extend `AuthService` and `AuthController` with invite-based registration logic.&#10;- Update admin layout to include &quot;Users&quot; link for easy access to user management." />
<MESSAGE value="Add &quot;My Bookings&quot; page and enable reservation updates and cancellations&#10;&#10;- Implement `+page.svelte` for managing user reservations with real-time updates.&#10;- Add API endpoints for updating and canceling reservations with enhanced validation and authorization.&#10;- Introduce `UpdateReservationDto` for refined reservation update handling.&#10;- Update `reservations.service.ts` to handle overlapping checks, time adjustments, and admin actions.&#10;- Extend layout to include a link to the &quot;My Bookings&quot; page.&#10;- Add `date-fns` library for date formatting." />
<MESSAGE value="Add building and floor management with admin editor modal&#10;&#10;- Implement `BuildingEditorModal.svelte` for creating, editing, and deleting buildings and floors.&#10;- Add `CreateBuildingDto`, `UpdateBuildingDto`, `CreateFloorDto`, and `UpdateFloorDto` for validation.&#10;- Extend `SpacesService` and `SpacesController` with endpoints for building and floor CRUD operations.&#10;- Update Prisma schema to include building descriptions and image URLs, and floor names.&#10;- Enhance dashboard with building management functionality for admin users." />
<option name="LAST_COMMIT_MESSAGE" value="Add building and floor management with admin editor modal&#10;&#10;- Implement `BuildingEditorModal.svelte` for creating, editing, and deleting buildings and floors.&#10;- Add `CreateBuildingDto`, `UpdateBuildingDto`, `CreateFloorDto`, and `UpdateFloorDto` for validation.&#10;- Extend `SpacesService` and `SpacesController` with endpoints for building and floor CRUD operations.&#10;- Update Prisma schema to include building descriptions and image URLs, and floor names.&#10;- Enhance dashboard with building management functionality for admin users." />
<MESSAGE value="Enhance real-time space status updates and handling&#10;&#10;- Improve WebSocket connection reliability with updated transport options.&#10;- Introduce logic to prioritize real-time updates over initial data for live views.&#10;- Add logging for better debugging of WebSocket events and data propagation.&#10;- Update UI components (`SpaceCard`, `FloorplanViewer`) to avoid overwrites from stale data.&#10;- Refactor date handling for consistent behavior across components." />
<MESSAGE value="Fix ownership checks and improve delete booking button UI&#10;&#10;- Update `isOwner` logic to account for nested user objects in reservation data.&#10;- Enhance delete button styling for better visibility and usability." />
<option name="LAST_COMMIT_MESSAGE" value="Fix ownership checks and improve delete booking button UI&#10;&#10;- Update `isOwner` logic to account for nested user objects in reservation data.&#10;- Enhance delete button styling for better visibility and usability." />
</component>
</project>

View File

@ -1,9 +0,0 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}

View File

@ -23,6 +23,7 @@ enum SpaceType {
model Organization {
id String @id @default(uuid())
name String
logoUrl String?
buildings Building[]
users User[]
createdAt DateTime @default(now())

View File

@ -8,10 +8,13 @@ async function main() {
const org = await prisma.organization.upsert({
where: { id: 'test-org-id' },
update: {},
update: {
logoUrl: 'https://images.unsplash.com/photo-1542744173-8e7e53415bb0?q=80&w=200&h=200&auto=format&fit=crop',
},
create: {
id: 'test-org-id',
name: 'Test Organization',
logoUrl: 'https://images.unsplash.com/photo-1542744173-8e7e53415bb0?q=80&w=200&h=200&auto=format&fit=crop',
},
});

View File

@ -9,6 +9,7 @@ import { ReservationsModule } from './reservations/reservations.module';
import { EventsModule } from './events/events.module';
import { ReportsModule } from './reports/reports.module';
import { UsersModule } from './users/users.module';
import { OrganizationsModule } from './organizations/organizations.module';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
@Module({
@ -21,6 +22,7 @@ import { LoggerMiddleware } from './common/middleware/logger.middleware';
EventsModule,
ReportsModule,
UsersModule,
OrganizationsModule,
],
controllers: [AppController],
providers: [AppService],

View File

@ -11,6 +11,7 @@ async function bootstrap() {
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
skipMissingProperties: false,
}),
);

View File

@ -0,0 +1,11 @@
import { IsString, IsOptional, IsUrl } from 'class-validator';
export class UpdateOrganizationDto {
@IsString()
@IsOptional()
name?: string;
@IsOptional()
@IsUrl()
logoUrl?: string | null;
}

View File

@ -0,0 +1,31 @@
import { Controller, Get, Patch, Body, Param, UseGuards, Req, ForbiddenException } from '@nestjs/common';
import { OrganizationsService } from './organizations.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';
import { UpdateOrganizationDto } from './dto/update-organization.dto';
@Controller('organizations')
@UseGuards(JwtAuthGuard, RolesGuard)
export class OrganizationsController {
constructor(private readonly organizationsService: OrganizationsService) {}
@Get(':id')
async findOne(@Param('id') id: string, @Req() req) {
// Only allow users to see their own organization (or implement more complex logic if needed)
if (req.user.organizationId !== id && req.user.role !== Role.ADMIN) {
throw new ForbiddenException('You can only access your own organization');
}
return this.organizationsService.findOne(id);
}
@Patch(':id')
@Roles(Role.ADMIN)
async update(@Param('id') id: string, @Req() req, @Body() updateOrganizationDto: UpdateOrganizationDto) {
if (req.user.organizationId !== id) {
throw new ForbiddenException('You can only update your own organization');
}
return this.organizationsService.update(id, updateOrganizationDto);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { OrganizationsService } from './organizations.service';
import { OrganizationsController } from './organizations.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [OrganizationsController],
providers: [OrganizationsService],
exports: [OrganizationsService],
})
export class OrganizationsModule {}

View File

@ -0,0 +1,25 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { UpdateOrganizationDto } from './dto/update-organization.dto';
@Injectable()
export class OrganizationsService {
constructor(private prisma: PrismaService) {}
async findOne(id: string) {
const org = await this.prisma.organization.findUnique({
where: { id },
});
if (!org) {
throw new NotFoundException(`Organization with ID ${id} not found`);
}
return org;
}
async update(id: string, updateOrganizationDto: UpdateOrganizationDto) {
return this.prisma.organization.update({
where: { id },
data: updateOrganizationDto,
});
}
}

View File

@ -0,0 +1,17 @@
import { writable } from "svelte/store";
import { apiFetch } from "$lib/api/client";
export const organization = writable<{
id: string;
name: string;
logoUrl?: string;
} | null>(null);
export async function loadOrganization(orgId: string) {
try {
const org = await apiFetch<any>(`/organizations/${orgId}`);
organization.set(org);
} catch (e) {
console.error("Failed to load organization", e);
}
}

View File

@ -1,19 +1,36 @@
<script lang="ts">
import "../app.css";
import { token, user } from "$lib/stores/auth";
import { organization, loadOrganization } from "$lib/stores/organization";
import { onMount } from "svelte";
onMount(async () => {
if ($user?.organizationId) {
await loadOrganization($user.organizationId);
}
});
$: if ($user?.organizationId && !$organization) {
loadOrganization($user.organizationId);
}
</script>
<div class="min-h-screen bg-gray-50">
<nav class="bg-white shadow-sm px-4 py-3 flex justify-between items-center">
<div class="flex items-center space-x-2">
{#if $organization?.logoUrl}
<img src={$organization.logoUrl} alt="Logo" class="w-8 h-8 rounded-lg object-contain" />
{:else}
<div class="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
<span class="text-white font-bold">H</span>
<span class="text-white font-bold">{$organization?.name?.charAt(0) || 'H'}</span>
</div>
<h1 class="text-xl font-bold text-gray-800">Hot Desking</h1>
{/if}
<h1 class="text-xl font-bold text-gray-800">{$organization?.name || 'Hot Desking'}</h1>
</div>
<div class="space-x-4 flex items-center">
{#if $token}
{#if $user?.role === 'ADMIN'}
<a href="/admin/organization" class="text-gray-600 hover:text-indigo-600 font-medium">Organization</a>
<a href="/admin/users" class="text-gray-600 hover:text-indigo-600 font-medium">Users</a>
<a href="/admin/reports" class="text-gray-600 hover:text-indigo-600 font-medium">Reports</a>
{/if}

View File

@ -0,0 +1,112 @@
<script lang="ts">
import { organization, loadOrganization } from "$lib/stores/organization";
import { user } from "$lib/stores/auth";
import { apiFetch } from "$lib/api/client";
import { onMount } from "svelte";
let name = "";
let logoUrl = "";
let loading = false;
let success = false;
let error = "";
onMount(async () => {
if ($user?.organizationId) {
await loadOrganization($user.organizationId);
if ($organization) {
name = $organization.name;
logoUrl = $organization.logoUrl || "";
}
}
});
async function handleSubmit() {
loading = true;
success = false;
error = "";
try {
const updated = await apiFetch<any>(`/organizations/${$user?.organizationId}`, {
method: 'PATCH',
body: JSON.stringify({ name, logoUrl: logoUrl || null }),
});
organization.set(updated);
success = true;
} catch (e: any) {
error = e.message || "Failed to update organization";
} finally {
loading = false;
}
}
function handleRemoveLogo() {
logoUrl = "";
}
</script>
<div class="max-w-2xl mx-auto py-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Organization Settings</h2>
<div class="bg-white shadow rounded-lg p-6">
<form on:submit|preventDefault={handleSubmit} class="space-y-6">
<div>
<label for="orgName" class="block text-sm font-medium text-gray-700">Organization Name</label>
<input
id="orgName"
type="text"
bind:value={name}
required
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="Enter organization name"
/>
</div>
<div>
<label for="logoUrl" class="block text-sm font-medium text-gray-700">Logo URL</label>
<input
id="logoUrl"
type="url"
bind:value={logoUrl}
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"
/>
<p class="mt-2 text-sm text-gray-500">Provide a URL for your organization's logo.</p>
</div>
{#if logoUrl}
<div class="mt-4">
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-medium text-gray-700">Logo Preview:</p>
<button
type="button"
on:click={handleRemoveLogo}
class="text-sm text-red-600 hover:text-red-800"
>
Remove Logo
</button>
</div>
<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" />
</div>
</div>
{/if}
{#if error}
<div class="text-red-600 text-sm mt-2">{error}</div>
{/if}
{#if success}
<div class="text-green-600 text-sm mt-2">Organization settings updated successfully!</div>
{/if}
<div class="flex justify-end">
<button
type="submit"
disabled={loading}
class="bg-indigo-600 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{loading ? "Saving..." : "Save Changes"}
</button>
</div>
</form>
</div>
</div>