Files
SRCmail/lib/resources/client.ts
T
Bernd Rodler 4fcd37650d feat: P2.8 Resources/Equipment Booking (PostgreSQL + VNCdirectory)
- PostgreSQL schema: resources + resources_bookings tables with indexes
- Server-side client with PG pool + in-memory fallback for dev
- API routes: list, get, availability check, book, cancel
- Resource store (Zustand) for client-side state
- ResourcePicker component: type filter, search, availability dots
- Integrated into event-modal: auto-book on save, auto-cancel on delete
- Integrated into free-busy-view: resource availability rows
2026-08-07 13:45:09 +02:00

288 lines
8.7 KiB
TypeScript

import { generateUUID } from '@/lib/utils';
export interface Resource {
id: string;
tenantId: string;
name: string;
type: 'room' | 'vehicle' | 'equipment' | 'other';
location?: string;
capacity?: number;
description?: string;
contactEmail?: string;
isActive: boolean;
metadata: Record<string, unknown>;
}
export interface ResourceBooking {
id: string;
resourceId: string;
eventId?: string;
startTime: string;
endTime: string;
bookedBy: string;
}
interface ResourceRow {
id: string;
tenant_id: string;
name: string;
type: string;
location: string | null;
capacity: number | null;
description: string | null;
contact_email: string | null;
is_active: boolean;
metadata: Record<string, unknown>;
}
interface BookingRow {
id: string;
resource_id: string;
event_id: string | null;
start_time: string;
end_time: string;
booked_by: string;
}
function rowToResource(row: ResourceRow): Resource {
return {
id: row.id,
tenantId: row.tenant_id,
name: row.name,
type: row.type as Resource['type'],
location: row.location ?? undefined,
capacity: row.capacity ?? undefined,
description: row.description ?? undefined,
contactEmail: row.contact_email ?? undefined,
isActive: row.is_active,
metadata: row.metadata ?? {},
};
}
function rowToBooking(row: BookingRow): ResourceBooking {
return {
id: row.id,
resourceId: row.resource_id,
eventId: row.event_id ?? undefined,
startTime: row.start_time,
endTime: row.end_time,
bookedBy: row.booked_by,
};
}
let pool: { query: (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }> } | null = null;
async function getPool(): Promise<{ query: (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }> } | null> {
if (pool) return pool;
const url = process.env.DATABASE_URL;
if (url) {
try {
// @ts-expect-error - pg is an optional runtime dependency, not in package.json
const pg = (await import('pg')) as unknown as { Pool?: new (cfg: { connectionString: string; max: number }) => unknown; default?: { Pool?: new (cfg: { connectionString: string; max: number }) => unknown } };
const PoolConstructor = (pg.Pool ?? pg.default?.Pool ?? null);
if (PoolConstructor) {
pool = new PoolConstructor({ connectionString: url, max: 10 }) as typeof pool;
}
console.log('[resources] PostgreSQL pool created');
return pool;
} catch {
console.warn('[resources] pg module not available, falling back to in-memory store');
}
}
console.warn('[resources] DATABASE_URL not set, using in-memory store');
return null;
}
const memoryResources: Map<string, ResourceRow> = new Map();
const memoryBookings: Map<string, BookingRow> = new Map();
export async function listResources(tenantId: string, type?: string): Promise<Resource[]> {
const db = await getPool();
if (db) {
let query = 'SELECT * FROM resources WHERE tenant_id = $1 AND is_active = true';
const params: string[] = [tenantId];
if (type) {
query += ' AND type = $2';
params.push(type);
}
query += ' ORDER BY name ASC';
const result = await db.query(query, params);
return (result.rows as ResourceRow[]).map(rowToResource);
}
let resources = Array.from(memoryResources.values()).filter(r => r.tenant_id === tenantId && r.is_active);
if (type) {
resources = resources.filter(r => r.type === type);
}
resources.sort((a, b) => a.name.localeCompare(b.name));
return resources.map(rowToResource);
}
export async function getResource(id: string): Promise<Resource | null> {
const db = await getPool();
if (db) {
const result = await db.query('SELECT * FROM resources WHERE id = $1', [id]);
if (result.rows.length === 0) return null;
return rowToResource(result.rows[0] as ResourceRow);
}
const row = memoryResources.get(id);
return row ? rowToResource(row) : null;
}
export async function createResource(
tenantId: string,
data: { name: string; type: Resource['type']; location?: string; capacity?: number; description?: string; contactEmail?: string; metadata?: Record<string, unknown> }
): Promise<Resource> {
const db = await getPool();
if (db) {
const result = await db.query(
`INSERT INTO resources (id, tenant_id, name, type, location, capacity, description, contact_email, metadata)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[tenantId, data.name, data.type, data.location ?? null, data.capacity ?? null, data.description ?? null, data.contactEmail ?? null, JSON.stringify(data.metadata ?? {})]
);
return rowToResource(result.rows[0] as ResourceRow);
}
const id = generateUUID();
const row: ResourceRow = {
id,
tenant_id: tenantId,
name: data.name,
type: data.type,
location: data.location ?? null,
capacity: data.capacity ?? null,
description: data.description ?? null,
contact_email: data.contactEmail ?? null,
is_active: true,
metadata: data.metadata ?? {},
};
memoryResources.set(id, row);
return rowToResource(row);
}
export async function checkAvailability(
resourceId: string,
start: string,
end: string,
): Promise<{ available: boolean; conflicts: ResourceBooking[] }> {
const db = await getPool();
if (db) {
const result = await db.query(
`SELECT * FROM resources_bookings
WHERE resource_id = $1
AND start_time < $3::timestamptz
AND end_time > $2::timestamptz
ORDER BY start_time ASC`,
[resourceId, start, end],
);
const conflicts = (result.rows as BookingRow[]).map(rowToBooking);
return { available: conflicts.length === 0, conflicts };
}
const conflicts = Array.from(memoryBookings.values())
.filter(b => b.resource_id === resourceId && b.start_time < end && b.end_time > start)
.sort((a, b) => a.start_time.localeCompare(b.start_time))
.map(rowToBooking);
return { available: conflicts.length === 0, conflicts };
}
export async function bookResource(
resourceId: string,
start: string,
end: string,
bookedBy: string,
eventId?: string,
): Promise<ResourceBooking> {
const db = await getPool();
if (db) {
const result = await db.query(
`INSERT INTO resources_bookings (id, resource_id, event_id, start_time, end_time, booked_by)
VALUES (gen_random_uuid(), $1, $2, $3::timestamptz, $4::timestamptz, $5) RETURNING *`,
[resourceId, eventId ?? null, start, end, bookedBy],
);
return rowToBooking(result.rows[0] as BookingRow);
}
const id = generateUUID();
const row: BookingRow = {
id,
resource_id: resourceId,
event_id: eventId ?? null,
start_time: start,
end_time: end,
booked_by: bookedBy,
};
memoryBookings.set(id, row);
return rowToBooking(row);
}
export async function cancelBooking(bookingId: string): Promise<void> {
const db = await getPool();
if (db) {
await db.query('DELETE FROM resources_bookings WHERE id = $1', [bookingId]);
return;
}
memoryBookings.delete(bookingId);
}
export async function getBookingsForResource(resourceId: string): Promise<ResourceBooking[]> {
const db = await getPool();
if (db) {
const result = await db.query(
'SELECT * FROM resources_bookings WHERE resource_id = $1 ORDER BY start_time ASC',
[resourceId],
);
return (result.rows as BookingRow[]).map(rowToBooking);
}
return Array.from(memoryBookings.values())
.filter(b => b.resource_id === resourceId)
.sort((a, b) => a.start_time.localeCompare(b.start_time))
.map(rowToBooking);
}
export async function getBookingsForEvent(eventId: string): Promise<ResourceBooking[]> {
const db = await getPool();
if (db) {
const result = await db.query(
'SELECT * FROM resources_bookings WHERE event_id = $1 ORDER BY start_time ASC',
[eventId],
);
return (result.rows as BookingRow[]).map(rowToBooking);
}
return Array.from(memoryBookings.values())
.filter(b => b.event_id === eventId)
.sort((a, b) => a.start_time.localeCompare(b.start_time))
.map(rowToBooking);
}
export async function cancelBookingsForEvent(eventId: string): Promise<void> {
const db = await getPool();
if (db) {
await db.query('DELETE FROM resources_bookings WHERE event_id = $1', [eventId]);
return;
}
for (const [id, booking] of memoryBookings) {
if (booking.event_id === eventId) {
memoryBookings.delete(id);
}
}
}
export async function updateBookingEventId(bookingId: string, eventId: string): Promise<void> {
const db = await getPool();
if (db) {
await db.query('UPDATE resources_bookings SET event_id = $2 WHERE id = $1', [bookingId, eventId]);
return;
}
const row = memoryBookings.get(bookingId);
if (row) {
row.event_id = eventId;
}
}