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 JWTconst jwtPayload = (c as unknown as { jwtPayload: JWTPayload }).jwtPayload;const userId = jwtPayload.sub;// Database connectionconst sql = neon(c.env.DATABASE_URL);const db = drizzle(sql);// Parse and validate request bodyconst body = await c.req.json();const { id, datasetId, locationId, name, description, timezoneId, sampleRate } = body;// Field validationif (!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 validationif (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 datasetconst 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 objectconst 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 yetsampleRate: sampleRate,createdBy: userId,createdAt: now,lastModified: now,modifiedBy: userId,active: true,};// Insert the clusterconst 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 violationsif (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 JWTconst jwtPayload = (c as unknown as { jwtPayload: JWTPayload }).jwtPayload;const userId = jwtPayload.sub;// Get cluster ID from URL parametersconst clusterId = c.req.param("id");// Parse request bodyconst body = await c.req.json();const { name, description, timezoneId, sampleRate, active } = body;// Connect to databaseconst sql = neon(c.env.DATABASE_URL);const db = drizzle(sql);// Check if cluster exists and get its dataset IDconst 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 datasetconst 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 fieldsconst updateData: Record<string, unknown> = {lastModified: new Date(),modifiedBy: userId,};// Validate and add name if providedif (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 providedif (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 providedif (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 providedif (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 clusterconst 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 stateconst [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 clusterconst 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 listsetClusters(prev => prev.map(cluster =>cluster.id === updatedCluster.id ? updatedCluster : cluster));} else {// Create new clusterconst 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 listsetClusters(prev => [newCluster, ...prev]);}// Reset form and close modalhandleCancelForm();} 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><ButtononClick={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">
<TableCellclassName={`font-medium whitespace-normal break-words ${onClusterSelect ? 'cursor-pointer' : ''}`}onClick={() => onClusterSelect && onClusterSelect(cluster.id, cluster.name)}>{cluster.name}</TableCell><TableCellclassName={`whitespace-normal break-words ${onClusterSelect ? 'cursor-pointer' : ''}`}onClick={() => onClusterSelect && onClusterSelect(cluster.id, cluster.name)}>{cluster.description || "—"}</TableCell><TableCellclassName={`whitespace-normal break-words ${onClusterSelect ? 'cursor-pointer' : ''}`}onClick={() => onClusterSelect && onClusterSelect(cluster.id, cluster.name)}>{cluster.timezoneId || "—"}</TableCell><TableCellclassName={`whitespace-normal break-words ${onClusterSelect ? 'cursor-pointer' : ''}`}onClick={() => onClusterSelect && onClusterSelect(cluster.id, cluster.name)}>{cluster.sampleRate ? `${cluster.sampleRate} Hz` : "—"}</TableCell><TableCellclassName={`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"><ButtononClick={(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><ButtononClick={(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><ButtononClick={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><inputtype="text"id="name"requiredmaxLength={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><textareaid="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><inputtype="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><inputtype="number"id="sampleRate"requiredmin="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"><Buttontype="button"onClick={handleCancelForm}variant="outline"disabled={formLoading}>Cancel</Button><Buttontype="submit"disabled={formLoading || !formData.name.trim() || !formData.sampleRate}>{formLoading? (isEditMode ? "Updating..." : "Creating..."): (isEditMode ? "Update Cluster" : "Create Cluster")}</Button></div></form></div></div>)}</>