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
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS resources (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('room', 'vehicle', 'equipment', 'other')),
|
||||
location TEXT,
|
||||
capacity INTEGER,
|
||||
description TEXT,
|
||||
contact_email TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resources_bookings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
||||
event_id TEXT,
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ NOT NULL,
|
||||
booked_by TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_tenant ON resources(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_type ON resources(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_resource_time ON resources_bookings(resource_id, start_time, end_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_time ON resources_bookings(start_time, end_time);
|
||||
Reference in New Issue
Block a user