LJINJQ4PMHP2JYM5EVW2XEO5GEXUPVY4OGGWQP56HUXJV43MLQGAC /*** Get all species associated with a specific dataset** @route GET /api/statistics/dataset-species* @authentication Required* @query {string} datasetId - The dataset ID to get species for* @returns {Object} Response containing:* - data: Array of species objects with id, label, and optional ebird_code* @description Returns all species that are linked to the specified dataset through the species_dataset junction table.* Requires READ permission on the dataset.*/statistics.get("/dataset-species", authenticate, async (c) => {try {const jwtPayload = (c as unknown as { jwtPayload: JWTPayload }).jwtPayload;const userId = jwtPayload.sub;// Get query parametersconst datasetId = c.req.query("datasetId");// Validate required parametersif (!datasetId) {return c.json({error: "Missing required query parameter: datasetId"}, 400);}const db = createDatabase(c.env);// Check if user has READ permission for this datasetconst hasPermission = await checkUserPermission(db, userId, datasetId, 'READ');if (!hasPermission) {return c.json({error: "Access denied: No READ permission for this dataset"}, 403);}// Get all species linked to this datasetconst speciesResult = await db.execute(sql`SELECT DISTINCT s.id, s.label, s.ebird_codeFROM species sJOIN species_dataset sd ON s.id = sd.species_idWHERE sd.dataset_id = ${datasetId}AND s.active = trueORDER BY s.label`);// Transform the resultconst speciesOptions = speciesResult.rows.map(row => ({id: row.id as string,label: row.label as string,ebird_code: row.ebird_code as string | null}));return c.json({data: speciesOptions});} catch (error) {console.error("Error fetching dataset species:", error);return c.json({error: "Failed to fetch dataset species",details: error instanceof Error ? error.message : String(error),},500);}});/*** Get all call types for a specific species** @route GET /api/statistics/species-call-types* @authentication Required* @query {string} speciesId - The species ID to get call types for* @returns {Object} Response containing:* - data: Array of call type objects with id and label* @description Returns all call types associated with the specified species.* Requires READ permission on at least one dataset containing the species.*/statistics.get("/species-call-types", authenticate, async (c) => {try {const jwtPayload = (c as unknown as { jwtPayload: JWTPayload }).jwtPayload;const userId = jwtPayload.sub;// Get query parametersconst speciesId = c.req.query("speciesId");// Validate required parametersif (!speciesId) {return c.json({error: "Missing required query parameter: speciesId"}, 400);}const db = createDatabase(c.env);// Check if user has READ permission for any dataset containing this speciesconst permissionResult = await db.execute(sql`SELECT DISTINCT sd.dataset_idFROM species_dataset sdWHERE sd.species_id = ${speciesId}`);if (permissionResult.rows.length === 0) {return c.json({error: "Species not found in any dataset"}, 404);}// Check permissions for at least one datasetlet hasPermission = false;for (const row of permissionResult.rows) {const datasetId = row.dataset_id as string;if (await checkUserPermission(db, userId, datasetId, 'READ')) {hasPermission = true;break;}}if (!hasPermission) {return c.json({error: "Access denied: No READ permission for datasets containing this species"}, 403);}// Get all call types for this speciesconst callTypesResult = await db.execute(sql`SELECT id, labelFROM call_typeWHERE species_id = ${speciesId}AND active = trueORDER BY label`);// Transform the resultconst callTypeOptions = callTypesResult.rows.map(row => ({id: row.id as string,label: row.label as string}));return c.json({data: callTypeOptions});} catch (error) {console.error("Error fetching species call types:", error);return c.json({error: "Failed to fetch species call types",details: error instanceof Error ? error.message : String(error),},500);}});
// Start transaction for atomic updateconst result = await db.transaction(async (tx) => {// Build update object with only provided fieldsconst updateData: Record<string, unknown> = {lastModified: new Date(),modifiedBy: userId,};
// Build update object with only provided fieldsconst updateData: Record<string, unknown> = {lastModified: new Date(),modifiedBy: userId,};
// Validate and add label if providedif (label !== undefined) {if (typeof label !== 'string' || label.trim().length === 0) {return c.json({error: "Field 'label' must be a non-empty string"}, 400);}if (label.length > 100) {return c.json({error: "Field 'label' must be 100 characters or less"}, 400);}updateData.label = label.trim();
// Validate and add label if providedif (label !== undefined) {if (typeof label !== 'string' || label.trim().length === 0) {return c.json({error: "Field 'label' must be a non-empty string"}, 400);}if (label.length > 100) {return c.json({error: "Field 'label' must be 100 characters or less"}, 400);
// 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 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 eBird code if providedif (ebirdCode !== undefined) {if (ebirdCode !== null && typeof ebirdCode !== 'string') {return c.json({error: "Field 'ebirdCode' must be a string or null"}, 400);}if (ebirdCode && ebirdCode.length > 12) {return c.json({error: "Field 'ebirdCode' must be 12 characters or less"}, 400);
// Validate and add eBird code if providedif (ebirdCode !== undefined) {if (ebirdCode !== null && typeof ebirdCode !== 'string') {
// 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;// If soft deleting species, also soft delete related recordsif (!active) {// Soft delete all call types for this speciesawait db.update(callType).set({active: false,lastModified: new Date(),modifiedBy: userId}).where(eq(callType.speciesId, speciesId));// Delete all species-dataset associations for this species (hard delete since no active field)await db.delete(speciesDataset).where(eq(speciesDataset.speciesId, speciesId));}}// Update the speciesconst [updatedSpecies] = await db.update(species).set(updateData).where(eq(species.id, speciesId)).returning({id: species.id,label: species.label,ebirdCode: species.ebirdCode,taxonomyVersion: species.taxonomyVersion,description: species.description,createdAt: species.createdAt,createdBy: species.createdBy,lastModified: species.lastModified,modifiedBy: species.modifiedBy,active: species.active,});// Handle call type updates if providedif (callTypes && Array.isArray(callTypes)) {// Validate call typesfor (const ct of callTypes) {if (!ct.label || typeof ct.label !== 'string' || ct.label.trim().length === 0) {
// Get existing call typesconst existingCallTypes = await db.select({id: callType.id,label: callType.label,}).from(callType).where(and(eq(callType.speciesId, speciesId), eq(callType.active, true)));// Update existing call typesfor (let i = 0; i < callTypes.length; i++) {const newCallType = callTypes[i];const existingCallType = existingCallTypes[i];
// 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);
if (existingCallType && existingCallType.label !== newCallType.label.trim()) {// Update existing call typeawait db.update(callType).set({label: newCallType.label.trim(),lastModified: new Date(),modifiedBy: userId}).where(eq(callType.id, existingCallType.id));} else if (!existingCallType) {// Create new call typeconst callTypeId = nanoid(12);await db.insert(callType).values({id: callTypeId,speciesId: speciesId,label: newCallType.label.trim(),createdBy: userId,createdAt: new Date(),lastModified: new Date(),modifiedBy: userId,active: true,});
updateData.active = active;// If soft deleting species, also soft delete related recordsif (!active) {// Soft delete all call types for this speciesawait tx
}// Soft delete any excess existing call typesif (existingCallTypes.length > callTypes.length) {for (let i = callTypes.length; i < existingCallTypes.length; i++) {await db
.where(eq(callType.speciesId, speciesId));// Delete all species-dataset associations for this species (hard delete since no active field)await tx.delete(speciesDataset).where(eq(speciesDataset.speciesId, speciesId));
.where(eq(callType.id, existingCallTypes[i].id));
// Update the speciesconst [updatedSpecies] = await tx.update(species).set(updateData).where(eq(species.id, speciesId)).returning({id: species.id,label: species.label,ebirdCode: species.ebirdCode,taxonomyVersion: species.taxonomyVersion,description: species.description,createdAt: species.createdAt,createdBy: species.createdBy,lastModified: species.lastModified,modifiedBy: species.modifiedBy,active: species.active,});
// Get updated call typesconst updatedCallTypes = await db.select({id: callType.id,label: callType.label,}).from(callType).where(and(eq(callType.speciesId, speciesId), eq(callType.active, true)));
// Get updated call typesconst callTypes = await tx.select({id: callType.id,label: callType.label,}).from(callType).where(and(eq(callType.speciesId, speciesId), eq(callType.active, true)));
const result = {...updatedSpecies,callTypes: updatedCallTypes};
import type { CallRateResponse, FilterOption, FiltersResponse, TimeFilter } from "../types/statistics";
import type { CallRateResponse, CallTypeOption, CallTypeResponse, FilterOption, FiltersResponse, SpeciesOption, SpeciesResponse, TimeFilter } from "../types/statistics";
const [availableSpecies, setAvailableSpecies] = useState<SpeciesOption[]>([]);const [speciesLoading, setSpeciesLoading] = useState(false);const [availableCallTypes, setAvailableCallTypes] = useState<CallTypeOption[]>([]);const [callTypesLoading, setCallTypesLoading] = useState(false);
// Default values for Haast Tokoekaconst speciesId = "ANhV8iZPfIh8"; // Haast Tokoeka
const [selectedSpeciesId, setSelectedSpeciesId] = useState<string>("ANhV8iZPfIh8"); // Default to Haast Tokoekaconst [selectedCallTypeId, setSelectedCallTypeId] = useState<string>(""); // Empty means "All Call Types"
// Fetch available species for this datasetuseEffect(() => {const fetchSpecies = async () => {if (!isAuthenticated) {if (!authLoading) {setSpeciesLoading(false);}return;}setSpeciesLoading(true);try {const accessToken = await getAccessToken();const response = await fetch(`/api/statistics/dataset-species?datasetId=${datasetId}`, {headers: {Authorization: `Bearer ${accessToken}`,},});if (!response.ok) {throw new Error(`Failed to fetch species: ${response.statusText}`);}const data: SpeciesResponse = await response.json();setAvailableSpecies(data.data);
// Set default species if available and current selection is not in the listif (data.data.length > 0 && !data.data.find(s => s.id === selectedSpeciesId)) {const defaultSpecies = data.data.find(s => s.id === "ANhV8iZPfIh8") || data.data[0];setSelectedSpeciesId(defaultSpecies.id);}} catch (err) {console.error("Error fetching species:", err);} finally {setSpeciesLoading(false);}};fetchSpecies();}, [datasetId, isAuthenticated, authLoading, getAccessToken, selectedSpeciesId]);// Fetch available call types for the selected speciesuseEffect(() => {const fetchCallTypes = async () => {if (!isAuthenticated || !selectedSpeciesId) {if (!authLoading) {setCallTypesLoading(false);}setAvailableCallTypes([]);setSelectedCallTypeId("");return;}setCallTypesLoading(true);try {const accessToken = await getAccessToken();const response = await fetch(`/api/statistics/species-call-types?speciesId=${selectedSpeciesId}`, {headers: {Authorization: `Bearer ${accessToken}`,},});if (!response.ok) {throw new Error(`Failed to fetch call types: ${response.statusText}`);}const data: CallTypeResponse = await response.json();setAvailableCallTypes(data.data);// Reset call type selection when species changessetSelectedCallTypeId("");} catch (err) {console.error("Error fetching call types:", err);setAvailableCallTypes([]);setSelectedCallTypeId("");} finally {setCallTypesLoading(false);}};fetchCallTypes();}, [selectedSpeciesId, isAuthenticated, authLoading, getAccessToken]);
id="speciesSelect"value={selectedSpeciesId}onChange={(e) => setSelectedSpeciesId(e.target.value)}disabled={speciesLoading || availableSpecies.length === 0}className="rounded-md border border-gray-300 bg-white py-1 px-3 text-sm shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:bg-gray-100">{speciesLoading? <option>Loading species...</option>: availableSpecies.length === 0? <option>No species available</option>: (availableSpecies.map(species => (<option key={species.id} value={species.id}>{species.label}</option>)))}</select></div>{availableCallTypes.length > 0 && (<div className="flex items-center"><selectid="callTypeSelect"value={selectedCallTypeId}onChange={(e) => setSelectedCallTypeId(e.target.value)}disabled={callTypesLoading}className="rounded-md border border-gray-300 bg-white py-1 px-3 text-sm shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:bg-gray-100"><option value="">All Call Types</option>{availableCallTypes.map(callType => (<option key={callType.id} value={callType.id}>{callType.label}</option>))}</select></div>)}<div className="flex items-center"><select