- 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
288 lines
8.7 KiB
TypeScript
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;
|
|
}
|
|
}
|