Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docker/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@ ADD COLUMN IF NOT EXISTS pii_redacted_at TIMESTAMPTZ;
ALTER TABLE IF EXISTS bookings
ALTER COLUMN booking_token SET DEFAULT app_random_token_64();

ALTER TABLE IF EXISTS bookings
ADD COLUMN IF NOT EXISTS session_notes TEXT;

CREATE TABLE IF NOT EXISTS waitlist_entries (
id BIGSERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL DEFAULT 1 REFERENCES tenants(id),
Expand Down
69 changes: 67 additions & 2 deletions packages/client/src/pages/ProjectDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { CalendarX, ClipboardCopy, Inbox, Pencil, Plus, Save, Trash2 } from 'lucide-react';

import type {
Booking,
ProjectDetail,
ProjectDetailResponse,
ProjectResponse,
Expand Down Expand Up @@ -45,6 +46,7 @@ interface ProjectSignupRow {
end_time: string;
booked_at: string;
cancelled_at: string | null;
session_notes: string | null;
}

interface EditTimeBlockState {
Expand Down Expand Up @@ -77,6 +79,60 @@ function toFormState(project: ProjectDetail): ProjectFormState {
};
}

function SessionNotesCell({ bookingId, initialNotes }: { bookingId: number; initialNotes: string | null }): JSX.Element {
const [notes, setNotes] = useState(initialNotes ?? '');
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const saveNotes = useCallback(async (value: string) => {
setSaveStatus('saving');
try {
await apiFetch<Booking>(`/bookings/${bookingId}/notes`, {
method: 'PUT',
body: JSON.stringify({ session_notes: value })
});
setSaveStatus('saved');
} catch {
setSaveStatus('idle');
}
}, [bookingId]);

const handleChange = useCallback((value: string) => {
setNotes(value);
setSaveStatus('idle');
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
void saveNotes(value);
}, 800);
}, [saveNotes]);

const handleBlur = useCallback(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = null;
}
void saveNotes(notes);
}, [notes, saveNotes]);

return (
<div className="session-notes-cell">
<textarea
className="session-notes-input"
value={notes}
onChange={(event) => handleChange(event.target.value)}
onBlur={handleBlur}
placeholder="Add notes about this session..."
rows={2}
maxLength={10000}
/>
{saveStatus === 'saving' && <span className="session-notes-status hint">Saving...</span>}
{saveStatus === 'saved' && <span className="session-notes-status hint">Saved</span>}
</div>
);
}

export function ProjectDetailPage(): JSX.Element {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
Expand Down Expand Up @@ -152,7 +208,8 @@ export function ProjectDetailPage(): JSX.Element {
start_time: block.start_time,
end_time: block.end_time,
booked_at: booking.booked_at,
cancelled_at: booking.cancelled_at
cancelled_at: booking.cancelled_at,
session_notes: booking.session_notes
}))
)
.sort((left, right) => {
Expand Down Expand Up @@ -648,6 +705,7 @@ export function ProjectDetailPage(): JSX.Element {
<th>Slot</th>
<th>Booked</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
Expand All @@ -663,6 +721,13 @@ export function ProjectDetailPage(): JSX.Element {
</td>
<td>{formatDateTimeInTimeZone(row.booked_at, timeZone)}</td>
<td>{row.cancelled_at ? 'Cancelled' : 'Active'}</td>
<td>
{row.cancelled_at ? (
<span className="hint">{row.session_notes || '—'}</span>
) : (
<SessionNotesCell bookingId={row.id} initialNotes={row.session_notes} />
)}
</td>
</tr>
))}
</tbody>
Expand Down
35 changes: 35 additions & 0 deletions packages/client/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -870,3 +870,38 @@ h3 > svg {
font-size: 0.85rem;
padding: 0.4rem 1rem;
}

/* Session Notes */
.session-notes-cell {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 180px;
}

.session-notes-input {
width: 100%;
min-height: 3rem;
padding: var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-family: inherit;
font-size: var(--font-size-sm);
resize: vertical;
background: var(--color-surface);
color: var(--color-text);
}

.session-notes-input:focus {
outline: 2px solid var(--color-primary);
outline-offset: -1px;
border-color: var(--color-primary);
}

.session-notes-input::placeholder {
color: var(--color-text-muted);
}

.session-notes-status {
font-size: var(--font-size-xs);
}
2 changes: 2 additions & 0 deletions packages/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getMetricsSnapshot } from './observability/metrics.js';
import adminRoutes from './routes/admin.js';
import authRoutes from './routes/auth.js';
import bookingRoutes from './routes/booking.js';
import bookingsRoutes from './routes/bookings.js';
import brandingRoutes from './routes/branding.js';
import dashboardRoutes from './routes/dashboard.js';
import projectRoutes from './routes/projects.js';
Expand Down Expand Up @@ -72,6 +73,7 @@ function mountApiVersionRoutes(app: express.Express, basePath: '/api' | '/api/v1
app.use(`${basePath}/projects`, projectRoutes);
app.use(`${basePath}/time-blocks`, timeBlockRoutes);
app.use(`${basePath}/schedule`, bookingRoutes);
app.use(`${basePath}/bookings`, bookingsRoutes);
app.use(`${basePath}/branding`, brandingRoutes);
app.use(`${basePath}/dashboard`, dashboardRoutes);
}
Expand Down
13 changes: 9 additions & 4 deletions packages/server/src/routes/booking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ router.post('/book/:shareToken', publicWriteRateLimiter, asyncHandler(async (req
)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, time_block_id, client_first_name, client_last_name,
client_email, client_phone, booking_token, booked_at, cancelled_at
client_email, client_phone, booking_token, booked_at, cancelled_at, session_notes
`,
[
project.tenant_id,
Expand Down Expand Up @@ -1041,6 +1041,7 @@ router.get('/booking/:bookingToken', publicReadRateLimiter, asyncHandler(async (
b.booking_token,
b.booked_at,
b.cancelled_at,
b.session_notes,
p.id AS project_id,
p.name AS project_name,
p.description AS project_description,
Expand Down Expand Up @@ -1132,7 +1133,8 @@ router.get('/booking/:bookingToken', publicReadRateLimiter, asyncHandler(async (
client_phone: bookingRow.client_phone,
booking_token: bookingRow.booking_token,
booked_at: bookingRow.booked_at,
cancelled_at: bookingRow.cancelled_at
cancelled_at: bookingRow.cancelled_at,
session_notes: bookingRow.session_notes ?? null
};

const currentSlot: CurrentBookingSlotInfo = {
Expand Down Expand Up @@ -1194,6 +1196,7 @@ router.post('/reschedule/:bookingToken', publicWriteRateLimiter, asyncHandler(as
b.booking_token,
b.booked_at,
b.cancelled_at,
b.session_notes,
tb.project_id,
p.name AS project_name,
p.description AS project_description,
Expand Down Expand Up @@ -1303,7 +1306,7 @@ router.post('/reschedule/:bookingToken', publicWriteRateLimiter, asyncHandler(as
)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, time_block_id, client_first_name, client_last_name,
client_email, client_phone, booking_token, booked_at, cancelled_at
client_email, client_phone, booking_token, booked_at, cancelled_at, session_notes
`,
[
currentBooking.tenant_id,
Expand Down Expand Up @@ -1473,6 +1476,7 @@ router.post('/cancel/:bookingToken', publicWriteRateLimiter, asyncHandler(async
b.booking_token,
b.booked_at,
b.cancelled_at,
b.session_notes,
p.name AS project_name,
p.description AS project_description,
tb.start_time,
Expand Down Expand Up @@ -1514,7 +1518,7 @@ router.post('/cancel/:bookingToken', publicWriteRateLimiter, asyncHandler(async
WHERE id = $1
AND tenant_id = $2
RETURNING id, time_block_id, client_first_name, client_last_name,
client_email, client_phone, booking_token, booked_at, cancelled_at
client_email, client_phone, booking_token, booked_at, cancelled_at, session_notes
`,
[booking.id, booking.tenant_id]
);
Expand Down Expand Up @@ -1614,6 +1618,7 @@ router.get('/calendar/:bookingToken', publicReadRateLimiter, asyncHandler(async
b.booking_token,
b.booked_at,
b.cancelled_at,
b.session_notes,
p.name AS project_name,
p.description AS project_description,
p.share_token,
Expand Down
115 changes: 115 additions & 0 deletions packages/server/src/routes/bookings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Router } from 'express';

import {
numericIdParamsSchema,
updateSessionNotesSchema,
type Booking
} from '@opencalendar/shared';

import { pool } from '../db/pool.js';
import { asyncHandler } from '../middleware/asyncHandler.js';
import { authMiddleware } from '../middleware/auth.js';

const router = Router();

// PUT /api/v1/bookings/:id/notes
router.put('/:id/notes', authMiddleware, asyncHandler(async (req, res) => {
if (!req.user) {
res.status(401).json({ error: 'Missing authenticated user' });
return;
}

const params = numericIdParamsSchema.parse(req.params);
const body = updateSessionNotesSchema.parse(req.body);

// Fetch the booking and verify it belongs to user's tenant + user has access
const bookingResult = await pool.query<Booking & { project_id: number; project_created_by: number }>(
`
SELECT
b.id,
b.time_block_id,
b.client_first_name,
b.client_last_name,
b.client_email,
b.client_phone,
b.booking_token,
b.booked_at,
b.cancelled_at,
b.session_notes,
tb.project_id,
p.created_by AS project_created_by
FROM bookings b
INNER JOIN time_blocks tb ON tb.id = b.time_block_id
INNER JOIN projects p ON p.id = tb.project_id
WHERE b.id = $1
AND b.tenant_id = $2
`,
[params.id, req.user.tenantId]
);

if (bookingResult.rows.length === 0) {
res.status(404).json({ error: 'Booking not found' });
return;
}

const booking = bookingResult.rows[0] as NonNullable<typeof bookingResult.rows[0]>;

if (booking.cancelled_at) {
res.status(400).json({ error: 'Cannot add notes to a cancelled booking' });
return;
}

// Authorization: PM who owns the project, admin, or engineer assigned to the project
const userId = req.user.userId;
const role = req.user.role;
let hasAccess = false;

if (role === 'admin') {
hasAccess = true;
} else if (role === 'pm' && booking.project_created_by === userId) {
hasAccess = true;
} else if (role === 'engineer') {
const assignmentResult = await pool.query(
`
SELECT 1
FROM time_block_engineers tbe
INNER JOIN time_blocks tb ON tb.id = tbe.time_block_id
WHERE tb.project_id = $1
AND tbe.engineer_id = $2
LIMIT 1
`,
[booking.project_id, userId]
);
hasAccess = assignmentResult.rows.length > 0;
}

if (!hasAccess) {
res.status(403).json({ error: 'You do not have permission to edit notes for this booking' });
return;
}

const updateResult = await pool.query<Booking>(
`
UPDATE bookings
SET session_notes = $1
WHERE id = $2
AND tenant_id = $3
RETURNING
id,
time_block_id,
client_first_name,
client_last_name,
client_email,
client_phone,
booking_token,
booked_at,
cancelled_at,
session_notes
`,
[body.session_notes, params.id, req.user.tenantId]
);

res.json(updateResult.rows[0]);
}));

export default router;
3 changes: 2 additions & 1 deletion packages/server/src/routes/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ router.get('/:id', authMiddleware, asyncHandler(async (req, res) => {
client_phone,
booking_token,
booked_at,
cancelled_at
cancelled_at,
session_notes
FROM bookings
WHERE time_block_id = ANY($1::int[])
AND tenant_id = $2
Expand Down
2 changes: 1 addition & 1 deletion packages/server/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/shared/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
updateUserStatusSchema,
updateProjectSchema,
updateBrandingSchema,
updateSessionNotesSchema,
tenantUidParamsSchema
} from './schemas.js';

Expand Down Expand Up @@ -61,4 +62,5 @@ export type ForgotPasswordRequest = z.infer<typeof forgotPasswordSchema>;
export type ResetPasswordRequest = z.infer<typeof resetPasswordSchema>;

export type UpdateBrandingRequest = z.infer<typeof updateBrandingSchema>;
export type UpdateSessionNotesRequest = z.infer<typeof updateSessionNotesSchema>;
export type TenantUidParams = z.infer<typeof tenantUidParamsSchema>;
4 changes: 4 additions & 0 deletions packages/shared/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,10 @@ export const rescheduleBookingSchema = z.object({
new_time_block_id: positiveInt
});

export const updateSessionNotesSchema = z.object({
session_notes: z.string().max(10000)
});

export const forgotPasswordSchema = z.object({
email: z.string().trim().email()
});
Expand Down
Loading
Loading