Remove NewBook room blocking — app never writes to NewBook

Unsellable flag is in-app visibility only; staff mark rooms out of
order in NewBook through their own process. NewBook use is now
read-only (room sync + occupancy filter).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-03 21:37:52 +00:00
parent 6ca395097e
commit 3289a31027
9 changed files with 11 additions and 114 deletions

View file

@ -101,7 +101,6 @@ export async function initDb() {
priority TEXT NOT NULL DEFAULT 'medium', -- low | medium | high | urgent priority TEXT NOT NULL DEFAULT 'medium', -- low | medium | high | urgent
status TEXT NOT NULL DEFAULT 'submitted', status TEXT NOT NULL DEFAULT 'submitted',
unusable BOOLEAN NOT NULL DEFAULT FALSE, unusable BOOLEAN NOT NULL DEFAULT FALSE,
newbook_blocked BOOLEAN NOT NULL DEFAULT FALSE,
hold_until DATE, hold_until DATE,
due_date DATE, due_date DATE,
assigned_type TEXT NOT NULL DEFAULT 'staff', -- staff | contractor assigned_type TEXT NOT NULL DEFAULT 'staff', -- staff | contractor
@ -140,7 +139,7 @@ export async function initDb() {
CREATE TABLE IF NOT EXISTS task_events ( CREATE TABLE IF NOT EXISTS task_events (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
task_id INT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, task_id INT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
event_type TEXT NOT NULL, -- created | status_change | reassigned | comment | photo | cost | newbook_block | newbook_unblock | reopened | edited event_type TEXT NOT NULL, -- created | status_change | reassigned | comment | photo | cost | reopened | edited
from_status TEXT, from_status TEXT,
to_status TEXT, to_status TEXT,
note TEXT, note TEXT,
@ -178,8 +177,6 @@ async function seedDefaults() {
urgent_notify_email: '', urgent_notify_email: '',
notify_on_assign: true, notify_on_assign: true,
notify_on_urgent: true, notify_on_urgent: true,
newbook_block_status: 'Maintenance',
newbook_unblock_status: 'Dirty',
} }
for (const [key, value] of Object.entries(defaults)) { for (const [key, value] of Object.entries(defaults)) {
await pool.query( await pool.query(

View file

@ -65,7 +65,3 @@ export async function fetchBookings(fromDate, toDate) {
return res?.data ?? [] return res?.data ?? []
} }
// Update room status in NewBook. NewBook expects 'status' parameter (not 'site_status').
export async function updateSiteStatus(siteId, status) {
return callApi('sites_update', { site_id: siteId, status })
}

View file

@ -1,7 +1,7 @@
import { requireAuth, requireCap, hasCap } from '../auth.js' import { requireAuth, requireCap, hasCap } from '../auth.js'
import { pool, getConfig } from '../db.js' import { pool, getConfig } from '../db.js'
import { createTask, logEvent, TRANSITIONS, PRIORITIES, STATUSES } from '../lib/task-core.js' import { createTask, logEvent, TRANSITIONS, PRIORITIES, STATUSES } from '../lib/task-core.js'
import { fetchBookings, updateSiteStatus } from '../lib/newbook.js' import { fetchBookings } from '../lib/newbook.js'
import { notifyAssignment } from '../lib/mailer.js' import { notifyAssignment } from '../lib/mailer.js'
const PRIORITY_ORDER = `CASE t.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END` const PRIORITY_ORDER = `CASE t.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END`
@ -266,43 +266,6 @@ export async function taskRoutes(app) {
return { ok: true, added_to_template: addedToTemplate } return { ok: true, added_to_template: addedToTemplate }
}) })
// POST /api/tasks/:id/newbook-block — set the room out of order in NewBook
app.post('/api/tasks/:id/newbook-block', { preHandler: requireCap('update') }, async (req, reply) => {
return toggleNewbookBlock(req, reply, true)
})
// POST /api/tasks/:id/newbook-unblock — release the room in NewBook
app.post('/api/tasks/:id/newbook-unblock', { preHandler: requireCap('update') }, async (req, reply) => {
return toggleNewbookBlock(req, reply, false)
})
async function toggleNewbookBlock(req, reply, block) {
const { rows } = await pool.query(
`SELECT t.*, l.newbook_site_id, l.source FROM tasks t JOIN locations l ON l.id = t.location_id WHERE t.id = $1`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Task not found' })
const task = rows[0]
if (task.source !== 'newbook' || !task.newbook_site_id) {
return reply.status(400).send({ error: 'Task location is not a NewBook room' })
}
const config = await getConfig()
const status = block ? (config.newbook_block_status || 'Maintenance') : (config.newbook_unblock_status || 'Dirty')
try {
await updateSiteStatus(task.newbook_site_id, status)
} catch (err) {
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
}
await pool.query('UPDATE tasks SET newbook_blocked = $1, updated_at = NOW() WHERE id = $2', [block, task.id])
await logEvent(task.id, block ? 'newbook_block' : 'newbook_unblock', {
note: `Room ${block ? 'blocked' : 'released'} in NewBook (status: ${status})`,
userName: req.user.name,
})
return { ok: true, newbook_blocked: block }
}
// GET /api/occupancy — today's in-house NewBook site ids (for the unoccupied filter UI) // GET /api/occupancy — today's in-house NewBook site ids (for the unoccupied filter UI)
app.get('/api/occupancy', { preHandler: requireCap('view') }, async (req, reply) => { app.get('/api/occupancy', { preHandler: requireCap('view') }, async (req, reply) => {
try { try {

View file

@ -84,12 +84,6 @@ export function resolveTask(id: number, body: {
export function addComment(id: number, note: string, addToTemplate = false): Promise<{ ok: boolean; added_to_template: boolean }> { export function addComment(id: number, note: string, addToTemplate = false): Promise<{ ok: boolean; added_to_template: boolean }> {
return request(`/tasks/${id}/comments`, { method: 'POST', body: JSON.stringify({ note, add_to_template: addToTemplate }) }) return request(`/tasks/${id}/comments`, { method: 'POST', body: JSON.stringify({ note, add_to_template: addToTemplate }) })
} }
export function blockRoomInNewbook(id: number): Promise<{ ok: boolean }> {
return request(`/tasks/${id}/newbook-block`, { method: 'POST' })
}
export function unblockRoomInNewbook(id: number): Promise<{ ok: boolean }> {
return request(`/tasks/${id}/newbook-unblock`, { method: 'POST' })
}
export function fetchOccupancy(): Promise<{ date: string; occupied_site_ids: string[] }> { export function fetchOccupancy(): Promise<{ date: string; occupied_site_ids: string[] }> {
return request('/occupancy') return request('/occupancy')
} }

View file

@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { X, Camera } from 'lucide-react' import { X, Camera } from 'lucide-react'
import type { Category, Location, Task, Priority, AppConfig, Asset } from '../types' import type { Category, Location, Task, Priority, AppConfig, Asset } from '../types'
import { PRIORITIES, PRIORITY_LABELS } from '../types' import { PRIORITIES, PRIORITY_LABELS } from '../types'
import { createTask, fetchTasks, uploadTaskPhoto, blockRoomInNewbook, fetchAssets } from '../api' import { createTask, fetchTasks, uploadTaskPhoto, fetchAssets } from '../api'
import AssigneeSelect, { type Assignment } from './AssigneeSelect' import AssigneeSelect, { type Assignment } from './AssigneeSelect'
import { PriorityBadge, StatusBadge } from './shared' import { PriorityBadge, StatusBadge } from './shared'
@ -33,7 +33,6 @@ export default function NewTaskModal({ categories, locations, config, onClose, o
contractor_id: config?.default_contractor_id ?? null, contractor_id: config?.default_contractor_id ?? null,
}) })
const location = useMemo(() => locations.find(l => l.id === locationId), [locations, locationId])
const locationAssets = useMemo( const locationAssets = useMemo(
() => assets.filter(a => a.location_id === locationId), () => assets.filter(a => a.location_id === locationId),
[assets, locationId] [assets, locationId]
@ -75,14 +74,6 @@ export default function NewTaskModal({ categories, locations, config, onClose, o
await uploadTaskPhoto(task.id, file, 'report').catch(() => {}) await uploadTaskPhoto(task.id, file, 'report').catch(() => {})
} }
// Explicit confirm — never block a room in NewBook silently
if (unusable && location?.source === 'newbook') {
const ok = window.confirm(
`Also mark ${location.name} as out of order in NewBook (status: ${config?.newbook_block_status || 'Maintenance'}) so it can't be sold?`
)
if (ok) await blockRoomInNewbook(task.id).catch(err => window.alert(`NewBook block failed: ${err.message}`))
}
onCreated() onCreated()
onClose() onClose()
} catch (err) { } catch (err) {
@ -157,10 +148,15 @@ export default function NewTaskModal({ categories, locations, config, onClose, o
</div> </div>
</div> </div>
<label className="field-check" style={{ marginBottom: 12 }}> <label className="field-check" style={{ marginBottom: unusable ? 4 : 12 }}>
<input type="checkbox" checked={unusable} onChange={e => setUnusable(e.target.checked)} /> <input type="checkbox" checked={unusable} onChange={e => setUnusable(e.target.checked)} />
Makes this location unusable / unsellable Makes this location unusable / unsellable
</label> </label>
{unusable && (
<div className="field-hint" style={{ marginBottom: 12 }}>
This flag is for visibility here only mark the room out of order in NewBook as usual.
</div>
)}
<div className="field-row"> <div className="field-row">
<div className="field"> <div className="field">

View file

@ -1,13 +1,13 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
X, Camera, MessageSquare, ArrowRight, RotateCcw, PlusCircle, X, Camera, MessageSquare, ArrowRight, RotateCcw, PlusCircle,
Ban, CheckCircle2, Image as ImageIcon, PoundSterling, UserRound, CheckCircle2, Image as ImageIcon, PoundSterling, UserRound,
} from 'lucide-react' } from 'lucide-react'
import type { TaskDetail, TaskStatus, TaskEvent, AuthUser } from '../types' import type { TaskDetail, TaskStatus, TaskEvent, AuthUser } from '../types'
import { STATUS_LABELS, TRANSITIONS, can } from '../types' import { STATUS_LABELS, TRANSITIONS, can } from '../types'
import { import {
fetchTask, updateTask, resolveTask, addComment, uploadTaskPhoto, deletePhoto, fetchTask, updateTask, resolveTask, addComment, uploadTaskPhoto, deletePhoto,
blockRoomInNewbook, unblockRoomInNewbook, photoUrl, fetchAssignableUsers, photoUrl, fetchAssignableUsers,
} from '../api' } from '../api'
import { useAuth } from './AuthGate' import { useAuth } from './AuthGate'
import AssigneeSelect, { type Assignment } from './AssigneeSelect' import AssigneeSelect, { type Assignment } from './AssigneeSelect'
@ -22,8 +22,6 @@ function EventIcon({ type }: { type: string }) {
case 'cost': return <PoundSterling {...props} /> case 'cost': return <PoundSterling {...props} />
case 'reassigned': return <UserRound {...props} /> case 'reassigned': return <UserRound {...props} />
case 'reopened': return <RotateCcw {...props} /> case 'reopened': return <RotateCcw {...props} />
case 'newbook_block': return <Ban {...props} />
case 'newbook_unblock': return <CheckCircle2 {...props} />
default: return <ArrowRight {...props} /> default: return <ArrowRight {...props} />
} }
} }
@ -107,7 +105,6 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
const canResolve = can(user, 'resolve') const canResolve = can(user, 'resolve')
const canReport = can(user, 'report') const canReport = can(user, 'report')
const showCosts = can(user, 'costs') const showCosts = can(user, 'costs')
const isRoom = task.location_source === 'newbook' && !!task.newbook_site_id
// Non-resolve transitions offered as buttons; resolve statuses open the resolve form // Non-resolve transitions offered as buttons; resolve statuses open the resolve form
const moves = (TRANSITIONS[task.status] || []).filter(s => !['temporary_fix', 'fixed'].includes(s)) const moves = (TRANSITIONS[task.status] || []).filter(s => !['temporary_fix', 'fixed'].includes(s))
@ -135,10 +132,6 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
note: resolveNote || undefined, note: resolveNote || undefined,
}) })
if (resolveFile) await uploadTaskPhoto(task!.id, resolveFile, 'resolution').catch(() => {}) if (resolveFile) await uploadTaskPhoto(task!.id, resolveFile, 'resolution').catch(() => {})
if (task!.newbook_blocked && status === 'fixed') {
const ok = window.confirm('This room is blocked in NewBook — release it now?')
if (ok) await unblockRoomInNewbook(task!.id).catch(() => {})
}
}) })
setShowResolve(null) setShowResolve(null)
} }
@ -153,7 +146,6 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
<StatusBadge status={task.status} /> <StatusBadge status={task.status} />
<PriorityBadge priority={task.priority} /> <PriorityBadge priority={task.priority} />
{task.unusable && <UnusableBadge />} {task.unusable && <UnusableBadge />}
{task.newbook_blocked && <span className="badge badge-outline">Blocked in NewBook</span>}
{task.template_id && <span className="badge badge-outline">Recurring</span>} {task.template_id && <span className="badge badge-outline">Recurring</span>}
</div> </div>
</div> </div>
@ -243,25 +235,6 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
</div> </div>
)} )}
{/* NewBook room block */}
{canUpdate && isRoom && task.status !== 'fixed' && (
<div className="chip-bar">
{task.newbook_blocked ? (
<button className="btn btn-sm" disabled={busy} onClick={() => run(() => unblockRoomInNewbook(task.id))}>
<CheckCircle2 size={13} strokeWidth={1.75} /> Release room in NewBook
</button>
) : (
<button className="btn btn-sm" disabled={busy} onClick={() => {
if (window.confirm(`Mark ${task.location_name} out of order in NewBook so it can't be sold?`)) {
run(() => blockRoomInNewbook(task.id))
}
}}>
<Ban size={13} strokeWidth={1.75} /> Block room in NewBook
</button>
)}
</div>
)}
{/* Resolve form */} {/* Resolve form */}
{showResolve && ( {showResolve && (
<div className="card" style={{ background: 'var(--ok-bg)' }}> <div className="card" style={{ background: 'var(--ok-bg)' }}>

View file

@ -100,24 +100,6 @@ export default function Settings() {
</div> </div>
<div className="field-hint">Uses the stack SMTP settings (Settings app integrations).</div> <div className="field-hint">Uses the stack SMTP settings (Settings app integrations).</div>
</div> </div>
<div className="section-title">NewBook room blocking</div>
<div className="card">
<div className="field-row">
<div className="field">
<label>Status set when blocking a room</label>
<input type="text" value={config.newbook_block_status} onChange={e => set({ newbook_block_status: e.target.value })} />
</div>
<div className="field">
<label>Status set when releasing a room</label>
<input type="text" value={config.newbook_unblock_status} onChange={e => set({ newbook_unblock_status: e.target.value })} />
</div>
</div>
<div className="field-hint">
Must match NewBook site status values exactly (e.g. Maintenance, Dirty). Blocking is always an explicit
per-task confirmation never automatic.
</div>
</div>
</div> </div>
) )
} }

View file

@ -115,7 +115,6 @@ export default function Summary() {
<div className="task-card-title"> <div className="task-card-title">
{t.title} {t.title}
{t.unusable && <UnusableBadge />} {t.unusable && <UnusableBadge />}
{t.newbook_blocked && <span className="badge badge-outline">NB blocked</span>}
{t.template_id && <span className="badge badge-outline">Recurring</span>} {t.template_id && <span className="badge badge-outline">Recurring</span>}
</div> </div>
<div className="task-card-meta"> <div className="task-card-meta">

View file

@ -76,7 +76,6 @@ export interface Task {
priority: Priority priority: Priority
status: TaskStatus status: TaskStatus
unusable: boolean unusable: boolean
newbook_blocked: boolean
hold_until: string | null hold_until: string | null
due_date: string | null due_date: string | null
assigned_type: AssignedType assigned_type: AssignedType
@ -213,8 +212,6 @@ export interface AppConfig {
urgent_notify_email: string urgent_notify_email: string
notify_on_assign: boolean notify_on_assign: boolean
notify_on_urgent: boolean notify_on_urgent: boolean
newbook_block_status: string
newbook_unblock_status: string
} }
export interface AuthUser { export interface AuthUser {