XTU6PGJEQEQ4WLCZDUQPDBB3H3JST4NO7QBPYJ3ZMKTZ4QH3U3OAC
U4CVCPSGPGYWJ4PA72HHHCHKJSQW3GU2QFK4YFSMKQOEM2MMY3PQC
UCDTBEK3CF6YT2H6V57HI6FAFW44BIYYAK3Z2QJ5LJE7QWX7OEYAC
YX7LU4WRAUDMWS3DEDXZDSF6DXBHLYDWVSMSRK6KIW3MO6GRXSVQC
POIBWSL3JFHT2KN3STFSJX3INSYKEJTX6KSW3N7BVEKWX2GJ6T7QC
4FBIL6IZUDNCXTM6EUHTEOJRHVI4LIIX4BU2IXPXKR362GKIAJMQC
M4UG5FMI5ICQRLJCCNSLV3ZKCGIXDQ65ECJM4PBE3NZYHT3CJLDAC
ROQGXQWL2V363K3W7TVVYKIAX4N4IWRERN5BJ7NYJRRVB6OMIJ4QC
HBM7XFBGVMKW3P3VZFNQTJMINZ4DC3D4TZMT4TPTJRXC62HKZMMQC
XEXJLBOH6HQAUZRUNH3CCPNUD4HRNCKMRZ5UJ6UUCO76KV6WUJAAC
M3JUJ2WWZGCVMBITKRM5FUJMHFYL2QRMXJUVRUE4AC2RF74AOL5AC
details: error instanceof Error ? error.message : String(error),
},
500
);
}
});
/**
* Protected API route to create a new cluster
*
* @route POST /api/clusters
* @authentication Required
* @param {Object} body - Cluster data including datasetId, locationId, name, description, timezoneId, sampleRate
* @returns {Object} Response containing:
* - data: Created cluster object
* @description Creates a new cluster with the authenticated user as owner.
* Requires EDIT permission on the dataset to create clusters within it.
*/
app.post("/api/clusters", authenticate, async (c) => {
try {
// Get user ID from JWT
const jwtPayload = (c as unknown as { jwtPayload: JWTPayload }).jwtPayload;
const userId = jwtPayload.sub;
// Database connection
const sql = neon(c.env.DATABASE_URL);
const db = drizzle(sql);
// Parse and validate request body
const body = await c.req.json();
const { id, datasetId, locationId, name, description, timezoneId, sampleRate } = body;
// Field validation
if (!id || typeof id !== 'string') {
return c.json({
error: "Missing or invalid required field: id"
}, 400);
}
if (!datasetId || typeof datasetId !== 'string') {
return c.json({
error: "Missing or invalid required field: datasetId"
}, 400);
}
if (!locationId || typeof locationId !== 'string') {
return c.json({
error: "Missing or invalid required field: locationId"
}, 400);
}
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return c.json({
error: "Missing or invalid required field: name"
}, 400);
}
if (!sampleRate || typeof sampleRate !== 'number' || sampleRate <= 0) {
return c.json({
error: "Missing or invalid required field: sampleRate"
}, 400);
}
// Length validation
if (id.length !== 12) {
return c.json({
error: "Field 'id' must be exactly 12 characters (nanoid)"
}, 400);
}
if (name.length > 140) {
return c.json({
error: "Field 'name' must be 140 characters or less"
}, 400);
}
if (description && description.length > 255) {
return c.json({
error: "Field 'description' must be 255 characters or less"
}, 400);
}
if (timezoneId && timezoneId.length > 40) {
return c.json({
error: "Field 'timezoneId' must be 40 characters or less"
}, 400);
}
// Check if user has EDIT permission on the dataset
const hasPermission = await checkUserPermission(db, userId, datasetId.trim(), 'EDIT');
if (!hasPermission) {
return c.json({
error: "You don't have permission to create clusters in this dataset"
}, 403);
}
// Create cluster object
const now = new Date();
const newCluster = {
id: id.trim(),
datasetId: datasetId.trim(),
locationId: locationId.trim(),
name: name.trim(),
description: description?.trim() || null,
timezoneId: timezoneId?.trim() || null,
cyclicRecordingPatternId: null, // Not supported in form yet
sampleRate: sampleRate,
createdBy: userId,
createdAt: now,
lastModified: now,
modifiedBy: userId,
active: true,
};
// Insert the cluster
const result = await db.insert(cluster).values(newCluster).returning({
id: cluster.id,
datasetId: cluster.datasetId,
locationId: cluster.locationId,
name: cluster.name,
description: cluster.description,
timezoneId: cluster.timezoneId,
cyclicRecordingPatternId: cluster.cyclicRecordingPatternId,
sampleRate: cluster.sampleRate,
createdAt: cluster.createdAt,
createdBy: cluster.createdBy,
lastModified: cluster.lastModified,
modifiedBy: cluster.modifiedBy,
active: cluster.active,
});
console.log("Created cluster:", result[0].id, "for location:", locationId, "by user:", userId);
return c.json({
data: result[0]
}, 201);
} catch (error) {
console.error("Error creating cluster:", error);
// Handle unique constraint violations
if (error instanceof Error && error.message.includes('duplicate key')) {
return c.json({
error: "A cluster with this ID already exists"
}, 400);
}
return c.json(
{
error: "Failed to create cluster",
details: error instanceof Error ? error.message : String(error),
},
500
);
}
});
/**
* Protected API route to update an existing cluster
*
* @route PUT /api/clusters/:id
* @authentication Required
* @param {string} id - Cluster ID in URL path
* @param {Object} body - Cluster data to update (name, description, timezoneId, sampleRate, active)
* @returns {Object} Response containing:
* - data: Updated cluster object
* @description Updates an existing cluster. Requires EDIT permission on the dataset.
*/
app.put("/api/clusters/:id", authenticate, async (c) => {
try {
// Get user from JWT
const jwtPayload = (c as unknown as { jwtPayload: JWTPayload }).jwtPayload;
const userId = jwtPayload.sub;
// Get cluster ID from URL parameters
const clusterId = c.req.param("id");
// Parse request body
const body = await c.req.json();
const { name, description, timezoneId, sampleRate, active } = body;
// Connect to database
const sql = neon(c.env.DATABASE_URL);
const db = drizzle(sql);
// Check if cluster exists and get its dataset ID
const existingCluster = await db
.select({
id: cluster.id,
datasetId: cluster.datasetId,
active: cluster.active
})
.from(cluster)
.where(eq(cluster.id, clusterId))
.limit(1);
if (existingCluster.length === 0) {
return c.json({
error: "Cluster not found"
}, 404);
}
const clusterRecord = existingCluster[0];
// Check if user has EDIT permission on the dataset
const hasEditPermission = await checkUserPermission(db, userId, clusterRecord.datasetId, 'EDIT');
if (!hasEditPermission) {
return c.json({
error: "You don't have permission to edit this cluster"
}, 403);
}
// Build update object with only provided fields
const updateData: Record<string, unknown> = {
lastModified: new Date(),
modifiedBy: userId,
};
// Validate and add name if provided
if (name !== undefined) {
if (typeof name !== 'string' || name.trim().length === 0) {
return c.json({
error: "Field 'name' must be a non-empty string"
}, 400);
}
if (name.length > 140) {
return c.json({
error: "Field 'name' must be 140 characters or less"
}, 400);
}
updateData.name = name.trim();
}
// Validate and add description if provided
if (description !== undefined) {
if (description !== null && typeof description !== 'string') {
return c.json({
error: "Field 'description' must be a string or null"
}, 400);
}
if (description && description.length > 255) {
return c.json({
error: "Field 'description' must be 255 characters or less"
}, 400);
}
updateData.description = description?.trim() || null;
}
// Validate and add timezoneId if provided
if (timezoneId !== undefined) {
if (timezoneId !== null && typeof timezoneId !== 'string') {
return c.json({
error: "Field 'timezoneId' must be a string or null"
}, 400);
}
if (timezoneId && timezoneId.length > 40) {
return c.json({
error: "Field 'timezoneId' must be 40 characters or less"
}, 400);
}
updateData.timezoneId = timezoneId?.trim() || null;
}
// Validate and add sampleRate if provided
if (sampleRate !== undefined) {
if (typeof sampleRate !== 'number' || sampleRate <= 0) {
return c.json({
error: "Field 'sampleRate' must be a positive number"
}, 400);
}
updateData.sampleRate = sampleRate;
}
// Add active status if provided (for soft delete)
if (active !== undefined) {
if (typeof active !== 'boolean') {
return c.json({
error: "Field 'active' must be a boolean"
}, 400);
}
updateData.active = active;
}
// Update the cluster
const result = await db
.update(cluster)
.set(updateData)
.where(eq(cluster.id, clusterId))
.returning({
id: cluster.id,
datasetId: cluster.datasetId,
locationId: cluster.locationId,
name: cluster.name,
description: cluster.description,
timezoneId: cluster.timezoneId,
cyclicRecordingPatternId: cluster.cyclicRecordingPatternId,
sampleRate: cluster.sampleRate,
createdAt: cluster.createdAt,
createdBy: cluster.createdBy,
lastModified: cluster.lastModified,
modifiedBy: cluster.modifiedBy,
active: cluster.active,
});
if (result.length === 0) {
return c.json({
error: "Failed to update cluster"
}, 500);
}
console.log("Updated cluster:", result[0].id, "by user:", userId);
return c.json({
data: result[0]
});
} catch (error) {
console.error("Error updating cluster:", error);
return c.json(
{
error: "Failed to update cluster",
const [showForm, setShowForm] = useState<boolean>(false);
const [formLoading, setFormLoading] = useState<boolean>(false);
const [formError, setFormError] = useState<string | null>(null);
const [editingCluster, setEditingCluster] = useState<Cluster | null>(null);
const [isEditMode, setIsEditMode] = useState<boolean>(false);
// Form state
const [formData, setFormData] = useState({
name: '',
description: '',
timezoneId: '',
sampleRate: '8000'
});
const handleSubmitCluster = async (e: React.FormEvent) => {
e.preventDefault();
if (!isAuthenticated) return;
setFormLoading(true);
setFormError(null);
try {
const accessToken = await getAccessToken();
if (isEditMode && editingCluster) {
// Update existing cluster
const response = await fetch(`/api/clusters/${editingCluster.id}`, {
method: "PUT",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: formData.name,
description: formData.description || null,
timezoneId: formData.timezoneId || null,
sampleRate: parseInt(formData.sampleRate),
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP error! Status: ${response.status}`);
}
const { data: updatedCluster } = await response.json();
// Update the cluster in the list
setClusters(prev => prev.map(cluster =>
cluster.id === updatedCluster.id ? updatedCluster : cluster
));
} else {
// Create new cluster
const id = nanoid(12);
const response = await fetch("/api/clusters", {
method: "POST",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
id,
datasetId,
locationId,
name: formData.name,
description: formData.description || null,
timezoneId: formData.timezoneId || null,
sampleRate: parseInt(formData.sampleRate),
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP error! Status: ${response.status}`);
}
const { data: newCluster } = await response.json();
// Add the new cluster to the list
setClusters(prev => [newCluster, ...prev]);
}
// Reset form and close modal
handleCancelForm();
} catch (err) {
setFormError(err instanceof Error ? err.message : `Failed to ${isEditMode ? 'update' : 'create'} cluster`);
console.error(`Error ${isEditMode ? 'updating' : 'creating'} cluster:`, err);
} finally {
setFormLoading(false);
}
};
const handleFormChange = (field: keyof typeof formData, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
const handleCancelForm = () => {
setShowForm(false);
setFormError(null);
setIsEditMode(false);
setEditingCluster(null);
setFormData({
name: '',
description: '',
timezoneId: '',
sampleRate: '8000'
});
};
const handleCreateNew = () => {
setIsEditMode(false);
setEditingCluster(null);
setFormData({
name: '',
description: '',
timezoneId: '',
sampleRate: '8000'
});
setShowForm(true);
};
const handleEditCluster = (cluster: Cluster) => {
setIsEditMode(true);
setEditingCluster(cluster);
setFormData({
name: cluster.name,
description: cluster.description || '',
timezoneId: cluster.timezoneId || '',
sampleRate: String(cluster.sampleRate)
});
setShowForm(true);
};
const handleDeleteCluster = async (cluster: Cluster) => {
if (!isAuthenticated) return;
const confirmDelete = window.confirm(`Are you sure you want to delete "${cluster.name}"? This action cannot be undone.`);
if (!confirmDelete) return;
try {
const accessToken = await getAccessToken();
const response = await fetch(`/api/clusters/${cluster.id}`, {
method: "PUT",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
active: false
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP error! Status: ${response.status}`);
}
// Remove the cluster from the list (soft delete)
setClusters(prev => prev.filter(c => c.id !== cluster.id));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete cluster");
console.error("Error deleting cluster:", err);
}
};
<div className="card p-6 bg-white shadow-sm rounded-lg">
<>
{/* Header with Add Button */}
{isAuthenticated && !authLoading && canCreateClusters && (
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Clusters</h2>
<Button
onClick={handleCreateNew}
variant="default"
size="sm"
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Add Cluster
</Button>
</div>
)}
<div className="card p-6 bg-white shadow-sm rounded-lg">
<TableCell className="font-medium whitespace-normal break-words">{cluster.name}</TableCell>
<TableCell className="whitespace-normal break-words">{cluster.description || "—"}</TableCell>
<TableCell className="whitespace-normal break-words">{cluster.timezoneId || "—"}</TableCell>
<TableCell className="whitespace-normal break-words">{cluster.sampleRate ? `${cluster.sampleRate} Hz` : "—"}</TableCell>
<TableCell className="whitespace-normal break-words">
<TableCell
className={`font-medium whitespace-normal break-words ${onClusterSelect ? 'cursor-pointer' : ''}`}
onClick={() => onClusterSelect && onClusterSelect(cluster.id, cluster.name)}
>
{cluster.name}
</TableCell>
<TableCell
className={`whitespace-normal break-words ${onClusterSelect ? 'cursor-pointer' : ''}`}
onClick={() => onClusterSelect && onClusterSelect(cluster.id, cluster.name)}
>
{cluster.description || "—"}
</TableCell>
<TableCell
className={`whitespace-normal break-words ${onClusterSelect ? 'cursor-pointer' : ''}`}
onClick={() => onClusterSelect && onClusterSelect(cluster.id, cluster.name)}
>
{cluster.timezoneId || "—"}
</TableCell>
<TableCell
className={`whitespace-normal break-words ${onClusterSelect ? 'cursor-pointer' : ''}`}
onClick={() => onClusterSelect && onClusterSelect(cluster.id, cluster.name)}
>
{cluster.sampleRate ? `${cluster.sampleRate} Hz` : "—"}
</TableCell>
<TableCell
className={`whitespace-normal break-words ${onClusterSelect ? 'cursor-pointer' : ''}`}
onClick={() => onClusterSelect && onClusterSelect(cluster.id, cluster.name)}
>
{canCreateClusters && (
<TableCell className="text-center">
<div className="flex justify-center gap-2">
<Button
onClick={(e) => {
e.stopPropagation();
handleEditCluster(cluster);
}}
variant="ghost"
size="sm"
className="p-1 h-8 w-8"
title="Edit cluster"
>
<Edit className="h-4 w-4" />
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
handleDeleteCluster(cluster);
}}
variant="ghost"
size="sm"
className="p-1 h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50"
title="Delete cluster"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
)}
</div>
</div>
{/* Create/Edit Cluster Modal */}
{showForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<div className="flex justify-between items-center mb-4">
<div>
<h3 className="text-lg font-semibold">
{isEditMode ? 'Edit Cluster' : 'Create New Cluster'}
</h3>
{!isEditMode && (
<p className="text-sm text-gray-600 mt-1">
Think of a cluster as data from one SD card, a recording session for one recorder
</p>
)}
</div>
<Button
onClick={handleCancelForm}
variant="ghost"
size="sm"
className="p-1"
>
<X className="h-4 w-4" />
</Button>
</div>
{formError && (
<div className="bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded mb-4">
{formError}
</div>
)}
<form onSubmit={handleSubmitCluster} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Name *
</label>
<input
type="text"
id="name"
required
maxLength={140}
value={formData.name}
onChange={(e) => handleFormChange('name', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter cluster name"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
id="description"
maxLength={255}
value={formData.description}
onChange={(e) => handleFormChange('description', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter description (optional)"
rows={3}
/>
</div>
<div>
<label htmlFor="timezoneId" className="block text-sm font-medium text-gray-700 mb-1">
Timezone ID
</label>
<input
type="text"
id="timezoneId"
maxLength={40}
value={formData.timezoneId}
onChange={(e) => handleFormChange('timezoneId', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g. Pacific/Auckland"
/>
</div>
<div>
<label htmlFor="sampleRate" className="block text-sm font-medium text-gray-700 mb-1">
Sample Rate (Hz) *
</label>
<input
type="number"
id="sampleRate"
required
min="1"
max="192000"
value={formData.sampleRate}
onChange={(e) => handleFormChange('sampleRate', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="8000"
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<Button
type="button"
onClick={handleCancelForm}
variant="outline"
disabled={formLoading}
>
Cancel
</Button>
<Button
type="submit"
disabled={formLoading || !formData.name.trim() || !formData.sampleRate}
>
{formLoading
? (isEditMode ? "Updating..." : "Creating...")
: (isEditMode ? "Update Cluster" : "Create Cluster")
}
</Button>
</div>
</form>
</div>
</div>
)}
</>