QTSC7SK437F5SFMYSP74I6WRWU2KSKYQJNPCPSSXWRMIFZZPQ6WQC
LD5U5NHLCULPSPQYYMHGHY3HJ3H5PPLZU4O4UALILXPLNAFVBMMQC
UCDTBEK3CF6YT2H6V57HI6FAFW44BIYYAK3Z2QJ5LJE7QWX7OEYAC
EUEH65HBT4XZXCNWECJXNDEQAWR2NLNSAXFPXLMQ27NOVMQBJT5QC
ROQGXQWL2V363K3W7TVVYKIAX4N4IWRERN5BJ7NYJRRVB6OMIJ4QC
YX7LU4WRAUDMWS3DEDXZDSF6DXBHLYDWVSMSRK6KIW3MO6GRXSVQC
M3JUJ2WWZGCVMBITKRM5FUJMHFYL2QRMXJUVRUE4AC2RF74AOL5AC
2WKGHT2TVFMQT7VUL5OCYVNE365QOYNRXA34LOZ4DBFJ2ZVB4XQQC
4FBIL6IZUDNCXTM6EUHTEOJRHVI4LIIX4BU2IXPXKR362GKIAJMQC
DOQBQX4IQSDBYYSBP4BEMTMJUKZPSC33KKXPAGZ3A5BRJMMKHCRQC
HM75N4NTZ4BBSSDC7TUSYOQ4SIF3G6KPZA5QRYCVCVRSKQVTJAXAC
2OYSY7VNMQ4C3CQMX7VKKD2JSZM72TJ3LL4GEDUQYG6RJTNIROLAC
ZYT3JRERMYXLMJHLPZYQHAINVMPQLBKGGN7A4C7OTVZDY42ZLTKQC
* Protected API route to search eBird taxonomy
*
* @route GET /api/ebird/search
* @authentication Required
* @param {string} q - Search query (common name, scientific name, family, or species code)
* @returns {Object} Response containing:
* - data: Array of matching eBird taxonomy entries
* @description Searches the eBird taxonomy v2024 materialized view for species matching the query.
* Searches across primary_com_name, sci_name, family, and species_code fields.
*/
app.get("/api/ebird/search", authenticate, async (c) => {
try {
const query = c.req.query("q");
if (!query || query.trim().length === 0) {
return c.json({
error: "Missing or empty query parameter 'q'"
}, 400);
}
if (query.length < 2) {
return c.json({
error: "Query must be at least 2 characters long"
}, 400);
}
// Database connection
const sql = neon(c.env.DATABASE_URL);
const db = drizzle(sql);
// Search across multiple fields with case-insensitive partial matching
const searchTerm = `%${query.trim().toLowerCase()}%`;
// Use raw SQL query for materialized view until Drizzle typing issue is resolved
const results = await db.execute(
sqlExpr`
SELECT
id,
species_code as "speciesCode",
primary_com_name as "primaryComName",
sci_name as "sciName",
bird_order as "birdOrder",
family
FROM ebird_taxonomy_v2024
WHERE
LOWER(primary_com_name) LIKE ${searchTerm} OR
LOWER(sci_name) LIKE ${searchTerm} OR
LOWER(family) LIKE ${searchTerm} OR
LOWER(species_code) LIKE ${searchTerm}
ORDER BY primary_com_name
LIMIT 20
`
);
return c.json({
data: results.rows || results
});
} catch (error) {
console.error("Error searching eBird taxonomy:", error);
return c.json(
{
error: "Failed to search eBird taxonomy",
details: error instanceof Error ? error.message : String(error),
},
500
);
}
});
/**
* Protected API route to create a new species
*
* @route POST /api/species
* @authentication Required
* @param {Object} body - Species data including datasetId, label, description, ebirdCode, callTypes
* @returns {Object} Response containing:
* - data: Created species object with call types
* @description Creates a new species with optional eBird integration and call types.
* Requires EDIT permission on the dataset. Creates entries in species, species_dataset,
* and optionally call_type tables in a single transaction.
*/
app.post("/api/species", 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, label, description, ebirdCode, callTypes } = 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 (!label || typeof label !== 'string' || label.trim().length === 0) {
return c.json({
error: "Missing or invalid required field: label"
}, 400);
}
// Length validation
if (id.length !== 12) {
return c.json({
error: "Field 'id' must be exactly 12 characters (nanoid)"
}, 400);
}
if (label.length > 100) {
return c.json({
error: "Field 'label' must be 100 characters or less"
}, 400);
}
if (description && description.length > 255) {
return c.json({
error: "Field 'description' must be 255 characters or less"
}, 400);
}
if (ebirdCode && ebirdCode.length > 12) {
return c.json({
error: "Field 'ebirdCode' must be 12 characters or less"
}, 400);
}
// Validate call types if provided
if (callTypes && (!Array.isArray(callTypes) || callTypes.some(ct => !ct.label || ct.label.length > 100))) {
return c.json({
error: "Field 'callTypes' must be an array of objects with 'label' field (max 100 chars)"
}, 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 species in this dataset"
}, 403);
}
// Start transaction for atomic creation
const result = await db.transaction(async (tx) => {
const now = new Date();
// Create species record
const newSpecies = {
id: id.trim(),
label: label.trim(),
ebirdCode: ebirdCode?.trim() || null,
taxonomyVersion: ebirdCode ? '2024' : null,
description: description?.trim() || null,
createdBy: userId,
createdAt: now,
lastModified: now,
modifiedBy: userId,
owner: userId,
active: true,
};
const [createdSpecies] = await tx.insert(species).values(newSpecies).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,
});
// Create species-dataset association
await tx.insert(speciesDataset).values({
speciesId: id.trim(),
datasetId: datasetId.trim(),
createdAt: now,
createdBy: userId,
lastModified: now,
modifiedBy: userId,
});
// Create call types if provided
const createdCallTypes = [];
if (callTypes && callTypes.length > 0) {
for (const callTypeData of callTypes) {
const callTypeId = nanoid(12);
const [createdCallType] = await tx.insert(callType).values({
id: callTypeId,
speciesId: id.trim(),
label: callTypeData.label.trim(),
createdBy: userId,
createdAt: now,
lastModified: now,
modifiedBy: userId,
active: true,
}).returning({
id: callType.id,
label: callType.label,
});
createdCallTypes.push(createdCallType);
}
}
return {
...createdSpecies,
callTypes: createdCallTypes
};
});
console.log("Created species:", result.id, "for dataset:", datasetId, "by user:", userId);
return c.json({
data: result
}, 201);
} catch (error) {
console.error("Error creating species:", error);
// Handle unique constraint violations
if (error instanceof Error && error.message.includes('duplicate key')) {
return c.json({
error: "A species with this ID already exists"
}, 400);
}
return c.json(
{
error: "Failed to create species",
details: error instanceof Error ? error.message : String(error),
},
500
);
}
});
/**
* Protected API route to update an existing species
*
* @route PUT /api/species/:id
* @authentication Required
* @param {string} id - Species ID in URL path
* @param {Object} body - Species data to update (label, description, ebirdCode, active)
* @returns {Object} Response containing:
* - data: Updated species object with call types
* @description Updates an existing species. Requires EDIT permission on the dataset.
* When soft deleting (active=false), also soft deletes all related call types.
*/
app.put("/api/species/:id", authenticate, async (c) => {
try {
// Get user from JWT
const jwtPayload = (c as unknown as { jwtPayload: JWTPayload }).jwtPayload;
const userId = jwtPayload.sub;
// Get species ID from URL parameters
const speciesId = c.req.param("id");
// Parse request body
const body = await c.req.json();
const { label, description, ebirdCode, active } = body;
// Connect to database
const sql = neon(c.env.DATABASE_URL);
const db = drizzle(sql);
// Check if species exists and get its associated datasets
const existingSpecies = await db
.select({
id: species.id,
active: species.active
})
.from(species)
.where(eq(species.id, speciesId))
.limit(1);
if (existingSpecies.length === 0) {
return c.json({
error: "Species not found"
}, 404);
}
// Get datasets associated with this species to check permissions
const associatedDatasets = await db
.select({ datasetId: speciesDataset.datasetId })
.from(speciesDataset)
.where(eq(speciesDataset.speciesId, speciesId));
if (associatedDatasets.length === 0) {
return c.json({
error: "Species not associated with any dataset"
}, 400);
}
// Check if user has EDIT permission on at least one associated dataset
let hasPermission = false;
for (const assoc of associatedDatasets) {
if (await checkUserPermission(db, userId, assoc.datasetId, 'EDIT')) {
hasPermission = true;
break;
}
}
if (!hasPermission) {
return c.json({
error: "You don't have permission to edit this species"
}, 403);
}
// Start transaction for atomic update
const result = await db.transaction(async (tx) => {
// Build update object with only provided fields
const updateData: Record<string, unknown> = {
lastModified: new Date(),
modifiedBy: userId,
};
// Validate and add label if provided
if (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 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 eBird code if provided
if (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);
}
updateData.ebirdCode = ebirdCode?.trim() || null;
updateData.taxonomyVersion = ebirdCode ? '2024' : null;
}
// 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 records
if (!active) {
// Soft delete all call types for this species
await tx
.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 tx
.delete(speciesDataset)
.where(eq(speciesDataset.speciesId, speciesId));
}
}
// Update the species
const [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 types
const callTypes = await tx
.select({
id: callType.id,
label: callType.label,
})
.from(callType)
.where(and(eq(callType.speciesId, speciesId), eq(callType.active, true)));
return {
...updatedSpecies,
callTypes
};
});
console.log("Updated species:", speciesId, "by user:", userId);
return c.json({
data: result
});
} catch (error) {
console.error("Error updating species:", error);
return c.json(
{
error: "Failed to update species",
details: error instanceof Error ? error.message : String(error),
},
500
);
}
});
/**
* Protected API route to create a new call type
*
* @route POST /api/call-types
* @authentication Required
* @param {Object} body - Call type data including id, speciesId, label
* @returns {Object} Response containing:
* - data: Created call type object
* @description Creates a new call type for a species. Requires EDIT permission on any dataset containing the species.
*/
app.post("/api/call-types", 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, speciesId, label } = body;
// Field validation
if (!id || typeof id !== 'string' || id.length !== 12) {
return c.json({
error: "Field 'id' must be exactly 12 characters (nanoid)"
}, 400);
}
if (!speciesId || typeof speciesId !== 'string') {
return c.json({
error: "Missing or invalid required field: speciesId"
}, 400);
}
if (!label || typeof label !== 'string' || label.trim().length === 0) {
return c.json({
error: "Missing or invalid required field: label"
}, 400);
}
if (label.length > 100) {
return c.json({
error: "Field 'label' must be 100 characters or less"
}, 400);
}
// Check if species exists and get its associated datasets
const associatedDatasets = await db
.select({ datasetId: speciesDataset.datasetId })
.from(speciesDataset)
.where(eq(speciesDataset.speciesId, speciesId));
if (associatedDatasets.length === 0) {
return c.json({
error: "Species not found or not associated with any dataset"
}, 404);
}
// Check if user has EDIT permission on at least one associated dataset
let hasPermission = false;
for (const assoc of associatedDatasets) {
if (await checkUserPermission(db, userId, assoc.datasetId, 'EDIT')) {
hasPermission = true;
break;
}
}
if (!hasPermission) {
return c.json({
error: "You don't have permission to create call types for this species"
}, 403);
}
// Create call type
const now = new Date();
const [createdCallType] = await db.insert(callType).values({
id: id.trim(),
speciesId: speciesId.trim(),
label: label.trim(),
createdBy: userId,
createdAt: now,
lastModified: now,
modifiedBy: userId,
active: true,
}).returning({
id: callType.id,
speciesId: callType.speciesId,
label: callType.label,
createdAt: callType.createdAt,
active: callType.active,
});
console.log("Created call type:", createdCallType.id, "for species:", speciesId, "by user:", userId);
return c.json({
data: createdCallType
}, 201);
} catch (error) {
console.error("Error creating call type:", error);
if (error instanceof Error && error.message.includes('duplicate key')) {
return c.json({
error: "A call type with this ID already exists"
}, 400);
}
return c.json(
{
error: "Failed to create call type",
details: error instanceof Error ? error.message : String(error),
},
500
);
}
});
/**
* Protected API route to update an existing call type
*
* @route PUT /api/call-types/:id
* @authentication Required
* @param {string} id - Call type ID in URL path
* @param {Object} body - Call type data to update (label, active)
* @returns {Object} Response containing:
* - data: Updated call type object
* @description Updates an existing call type. Requires EDIT permission on any dataset containing the parent species.
*/
app.put("/api/call-types/:id", authenticate, async (c) => {
try {
// Get user from JWT
const jwtPayload = (c as unknown as { jwtPayload: JWTPayload }).jwtPayload;
const userId = jwtPayload.sub;
// Get call type ID from URL parameters
const callTypeId = c.req.param("id");
// Parse request body
const body = await c.req.json();
const { label, active } = body;
// Connect to database
const sql = neon(c.env.DATABASE_URL);
const db = drizzle(sql);
// Check if call type exists and get its species
const existingCallType = await db
.select({
id: callType.id,
speciesId: callType.speciesId,
active: callType.active
})
.from(callType)
.where(eq(callType.id, callTypeId))
.limit(1);
if (existingCallType.length === 0) {
return c.json({
error: "Call type not found"
}, 404);
}
const speciesId = existingCallType[0].speciesId;
// Get datasets associated with the species to check permissions
const associatedDatasets = await db
.select({ datasetId: speciesDataset.datasetId })
.from(speciesDataset)
.where(eq(speciesDataset.speciesId, speciesId));
// Check if user has EDIT permission on at least one associated dataset
let hasPermission = false;
for (const assoc of associatedDatasets) {
if (await checkUserPermission(db, userId, assoc.datasetId, 'EDIT')) {
hasPermission = true;
break;
}
}
if (!hasPermission) {
return c.json({
error: "You don't have permission to edit this call type"
}, 403);
}
// Build update object with only provided fields
const updateData: Record<string, unknown> = {
lastModified: new Date(),
modifiedBy: userId,
};
// Validate and add label if provided
if (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();
}
// 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 call type
const [updatedCallType] = await db
.update(callType)
.set(updateData)
.where(eq(callType.id, callTypeId))
.returning({
id: callType.id,
speciesId: callType.speciesId,
label: callType.label,
createdAt: callType.createdAt,
lastModified: callType.lastModified,
active: callType.active,
});
console.log("Updated call type:", callTypeId, "by user:", userId);
return c.json({
data: updatedCallType
});
} catch (error) {
console.error("Error updating call type:", error);
return c.json(
{
error: "Failed to update call type",
details: error instanceof Error ? error.message : String(error),
},
500
);
}
});
/**
const [showForm, setShowForm] = useState<boolean>(false);
const [formLoading, setFormLoading] = useState<boolean>(false);
const [formError, setFormError] = useState<string | null>(null);
const [editingSpecies, setEditingSpecies] = useState<Species | null>(null);
const [isEditMode, setIsEditMode] = useState<boolean>(false);
// Form state
const [formData, setFormData] = useState({
label: '',
description: '',
ebirdCode: '',
ebirdData: null as EbirdSearchResult | null
});
// Call types state
const [callTypes, setCallTypes] = useState<Array<{id: string, label: string}>>([]);
const handleSubmitSpecies = async (e: React.FormEvent) => {
e.preventDefault();
if (!isAuthenticated) return;
setFormLoading(true);
setFormError(null);
try {
const accessToken = await getAccessToken();
if (isEditMode && editingSpecies) {
// Update existing species
const response = await fetch(`/api/species/${editingSpecies.id}`, {
method: "PUT",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
label: formData.label,
description: formData.description || null,
ebirdCode: formData.ebirdCode || null,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP error! Status: ${response.status}`);
}
const { data: updatedSpecies } = await response.json();
// Update the species in the list
setSpecies(prev => prev.map(s =>
s.id === updatedSpecies.id ? updatedSpecies : s
));
} else {
// Create new species
const id = nanoid(12);
const response = await fetch("/api/species", {
method: "POST",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
id,
datasetId,
label: formData.label,
description: formData.description || null,
ebirdCode: formData.ebirdCode || null,
callTypes: callTypes.filter(ct => ct.label.trim()).map(ct => ({ label: ct.label })),
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP error! Status: ${response.status}`);
}
const { data: newSpecies } = await response.json();
// Add the new species to the list
setSpecies(prev => [newSpecies, ...prev]);
}
// Reset form and close modal
handleCancelForm();
} catch (err) {
setFormError(err instanceof Error ? err.message : `Failed to ${isEditMode ? 'update' : 'add'} species`);
console.error(`Error ${isEditMode ? 'updating' : 'adding'} species:`, err);
} finally {
setFormLoading(false);
}
};
const handleFormChange = (field: keyof typeof formData, value: string | EbirdSearchResult | null) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
const handleCancelForm = () => {
setShowForm(false);
setFormError(null);
setIsEditMode(false);
setEditingSpecies(null);
setFormData({
label: '',
description: '',
ebirdCode: '',
ebirdData: null
});
setCallTypes([]);
};
const handleCreateNew = () => {
setIsEditMode(false);
setEditingSpecies(null);
setFormData({
label: '',
description: '',
ebirdCode: '',
ebirdData: null
});
setCallTypes([]);
setShowForm(true);
};
const handleEditSpecies = (speciesItem: Species) => {
setIsEditMode(true);
setEditingSpecies(speciesItem);
// If species has an eBird code, create a mock eBird data object for display
const ebirdData = speciesItem.ebirdCode ? {
id: '',
speciesCode: speciesItem.ebirdCode,
primaryComName: speciesItem.label,
sciName: '',
birdOrder: null,
family: null
} : null;
setFormData({
label: speciesItem.label,
description: speciesItem.description || '',
ebirdCode: speciesItem.ebirdCode || '',
ebirdData: ebirdData
});
setCallTypes(speciesItem.callTypes || []);
setShowForm(true);
};
const handleDeleteSpecies = async (speciesItem: Species) => {
if (!isAuthenticated) return;
const confirmDelete = window.confirm(`Are you sure you want to delete "${speciesItem.label}"? This will also delete all associated call types. This action cannot be undone.`);
if (!confirmDelete) return;
try {
const accessToken = await getAccessToken();
const response = await fetch(`/api/species/${speciesItem.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 species from the list (soft delete)
setSpecies(prev => prev.filter(s => s.id !== speciesItem.id));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete species");
console.error("Error deleting species:", err);
}
};
const handleEbirdSelect = (ebirdSpecies: EbirdSearchResult) => {
handleFormChange('ebirdData', ebirdSpecies);
handleFormChange('ebirdCode', ebirdSpecies.speciesCode);
// Auto-fill label if empty
if (!formData.label) {
handleFormChange('label', ebirdSpecies.primaryComName);
}
};
const addCallType = () => {
setCallTypes(prev => [...prev, { id: nanoid(12), label: '' }]);
};
const updateCallType = (index: number, label: string) => {
setCallTypes(prev => prev.map((ct, i) => i === index ? { ...ct, label } : ct));
};
const removeCallType = (index: number) => {
setCallTypes(prev => prev.filter((_, i) => i !== index));
};
<div className="card p-6 bg-white shadow-sm rounded-lg">
<>
{/* Header with Add Button */}
{isAuthenticated && !authLoading && canCreateSpecies && (
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Species</h2>
<Button
onClick={handleCreateNew}
variant="default"
size="sm"
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Add Species
</Button>
</div>
)}
<div className="card p-6 bg-white shadow-sm rounded-lg">
<TableCell className="font-medium whitespace-normal break-words">{speciesItem.label}</TableCell>
<TableCell className="whitespace-normal break-words">{speciesItem.ebirdCode || "—"}</TableCell>
<TableCell className="whitespace-normal break-words">{speciesItem.description || "—"}</TableCell>
<TableCell className="whitespace-normal break-words">
<TableCell
className={`font-medium whitespace-normal break-words ${onSpeciesSelect ? 'cursor-pointer' : ''}`}
onClick={() => handleSpeciesClick(speciesItem)}
>
{speciesItem.label}
</TableCell>
<TableCell
className={`whitespace-normal break-words ${onSpeciesSelect ? 'cursor-pointer' : ''}`}
onClick={() => handleSpeciesClick(speciesItem)}
>
{speciesItem.ebirdCode || "—"}
</TableCell>
<TableCell
className={`whitespace-normal break-words ${onSpeciesSelect ? 'cursor-pointer' : ''}`}
onClick={() => handleSpeciesClick(speciesItem)}
>
{speciesItem.description || "—"}
</TableCell>
<TableCell
className={`whitespace-normal break-words ${onSpeciesSelect ? 'cursor-pointer' : ''}`}
onClick={() => handleSpeciesClick(speciesItem)}
>
{canCreateSpecies && (
<TableCell className="text-center">
<div className="flex justify-center gap-2">
<Button
onClick={(e) => {
e.stopPropagation();
handleEditSpecies(speciesItem);
}}
variant="ghost"
size="sm"
className="p-1 h-8 w-8"
title="Edit species"
>
<Edit className="h-4 w-4" />
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
handleDeleteSpecies(speciesItem);
}}
variant="ghost"
size="sm"
className="p-1 h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50"
title="Delete species"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
)}
</div>
</div>
{/* Create/Edit Species 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-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">
{isEditMode ? 'Edit Species' : 'Add New Species'}
</h3>
<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={handleSubmitSpecies} className="space-y-6">
{/* Basic Information */}
<div className="space-y-4">
<h4 className="font-medium text-gray-900">Basic Information</h4>
<div>
<label htmlFor="label" className="block text-sm font-medium text-gray-700 mb-1">
Species Label *
</label>
<input
type="text"
id="label"
required
maxLength={100}
value={formData.label}
onChange={(e) => handleFormChange('label', 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. Brown Kiwi, Green Gecko, Bell Frog"
/>
</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="Optional description"
rows={3}
/>
</div>
</div>
{/* eBird Integration */}
<div className="space-y-4">
<h4 className="font-medium text-gray-900">eBird Integration (Optional)</h4>
{formData.ebirdData ? (
<div className="p-3 bg-gray-50 border border-gray-200 rounded-md">
<div className="flex justify-between items-start">
<div>
<div className="font-medium text-gray-800">{formData.ebirdData.primaryComName}</div>
{formData.ebirdData.sciName && (
<div className="text-sm text-gray-600 italic">{formData.ebirdData.sciName}</div>
)}
<div className="text-sm text-gray-600">Code: {formData.ebirdData.speciesCode}</div>
{isEditMode && (
<div className="text-xs text-gray-500 mt-1">Current eBird selection</div>
)}
</div>
<Button
type="button"
onClick={() => {
handleFormChange('ebirdData', null);
handleFormChange('ebirdCode', '');
}}
variant="ghost"
size="sm"
className="p-1 text-gray-600 hover:bg-gray-100"
title="Clear eBird selection"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<div>
<p className="text-sm text-gray-600 mb-3">Search for bird species in the eBird taxonomy. Leave empty for non-bird species.</p>
<label className="block text-sm font-medium text-gray-700 mb-1">
Search eBird Species
</label>
<EbirdSearch
onSelectSpecies={handleEbirdSelect}
placeholder="Search by common name, scientific name, or family..."
/>
</div>
)}
</div>
{/* Call Types */}
{!isEditMode && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h4 className="font-medium text-gray-900">Call Types (Optional)</h4>
<Button
type="button"
onClick={addCallType}
variant="outline"
size="sm"
className="flex items-center gap-1"
>
<Plus className="h-3 w-3" />
Add Call Type
</Button>
</div>
<p className="text-sm text-gray-600">Examples: male, female, duet, trill, hunting, begging</p>
{callTypes.map((callType, index) => (
<div key={callType.id} className="flex gap-2">
<input
type="text"
value={callType.label}
onChange={(e) => updateCallType(index, e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Call type label"
maxLength={100}
/>
<Button
type="button"
onClick={() => removeCallType(index)}
variant="ghost"
size="sm"
className="p-2 text-red-600 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
<div className="flex justify-end space-x-3 pt-4 border-t">
<Button
type="button"
onClick={handleCancelForm}
variant="outline"
disabled={formLoading}
>
Cancel
</Button>
<Button
type="submit"
disabled={formLoading || !formData.label.trim()}
>
{formLoading
? (isEditMode ? "Updating..." : "Adding...")
: (isEditMode ? "Update Species" : "Add Species")
}
</Button>
</div>
</form>
</div>
</div>
)}
</>
// Drizzle handles materialized views differently, excluded the materialized view `ebird_taxonomy_v2024` from this conversion. Implement that at the query level in application code.
// Materialized view `ebird_taxonomy_v2024` is now included and can be queried using Drizzle.
import { pgEnum, pgTable, varchar, boolean, timestamp, date, decimal, integer, json, primaryKey, index, unique, foreignKey, uniqueIndex, bigint } from "drizzle-orm/pg-core";
import { pgEnum, pgTable, pgMaterializedView, varchar, boolean, timestamp, date, decimal, integer, json, primaryKey, index, unique, foreignKey, uniqueIndex, bigint } from "drizzle-orm/pg-core";
// eBird Taxonomy Materialized View (2024 version)
// This materialized view provides access to the 2024 eBird taxonomy data
export const ebirdTaxonomyV2024 = pgMaterializedView("ebird_taxonomy_v2024", {
id: varchar("id", { length: 12 }).primaryKey(),
speciesCode: varchar("species_code", { length: 15 }),
primaryComName: varchar("primary_com_name", { length: 100 }),
sciName: varchar("sci_name", { length: 100 }),
birdOrder: varchar("bird_order", { length: 30 }),
family: varchar("family", { length: 100 }),
});