cropintel / src /lib /farms.ts
Jaithra Polavarapu
feat: add Firebase auth/farms backend alongside existing frontend
fb030a8
Raw
History Blame Contribute Delete
12.7 kB
'use client'
import {
addDoc,
collection,
deleteDoc,
doc,
getDoc,
getDocs,
limit,
query,
runTransaction,
serverTimestamp,
setDoc,
Timestamp,
updateDoc,
where,
writeBatch,
} from 'firebase/firestore'
import { createFarmSchema, farmSearchSchema, joinCodeSchema } from '@/lib/security/validation'
import { getFirebaseDb } from './firebase'
import {
buildFarmAccessRequestId,
buildFarmMemberId,
canLeaveFarm,
canResolveAccessRequest,
withDisplayAccessRequestStatus,
} from './farmAccess'
import type { Diagnosis, Farm, FarmAccessRequest, FarmMember, FarmSearchResult } from './types'
const JOIN_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const ACCESS_REQUEST_DAYS = 30
export type CreateFarmInput = {
name: string
address: string
stateCode: string
crops: string[]
acreage?: number | null
lat?: number | null
lng?: number | null
}
function makeJoinCode() {
const bytes = new Uint8Array(6)
crypto.getRandomValues(bytes)
let code = ''
for (let index = 0; index < bytes.length; index += 1) {
code += JOIN_CODE_CHARS[bytes[index] % JOIN_CODE_CHARS.length]
}
return code
}
function normalizeSearchText(value: string) {
return value.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim()
}
function buildSearchTokens(farm: Pick<Farm, 'name' | 'address' | 'stateCode'>) {
const source = normalizeSearchText(`${farm.name} ${farm.address} ${farm.stateCode}`)
const tokens = new Set<string>()
for (const word of source.split(' ')) {
if (word.length >= 2) tokens.add(word.slice(0, 24))
}
return Array.from(tokens).slice(0, 40)
}
function requestExpiresAt() {
return Timestamp.fromDate(new Date(Date.now() + ACCESS_REQUEST_DAYS * 24 * 60 * 60 * 1000))
}
async function createUniqueJoinCode(
batch: ReturnType<typeof writeBatch>,
farmId: string,
userId: string,
attempts = 8
) {
const db = getFirebaseDb()
for (let attempt = 0; attempt < attempts; attempt += 1) {
const joinCode = makeJoinCode()
const joinCodeRef = doc(db, 'farmJoinCodes', joinCode)
const existingCode = await getDoc(joinCodeRef)
if (existingCode.exists()) continue
batch.set(joinCodeRef, {
farmId,
createdBy: userId,
createdAt: serverTimestamp(),
})
return joinCode
}
throw new Error('Could not generate a unique farm join code. Please try again.')
}
export async function createFarmForUser(userId: string, input: CreateFarmInput) {
const db = getFirebaseDb()
const validated = createFarmSchema.parse({
...input,
stateCode: input.stateCode.trim().toUpperCase(),
})
const farmRef = doc(collection(db, 'farms'))
const memberRef = doc(db, 'farmMembers', buildFarmMemberId(farmRef.id, userId))
const searchRef = doc(db, 'farmSearchIndex', farmRef.id)
const batch = writeBatch(db)
const joinCode = await createUniqueJoinCode(batch, farmRef.id, userId)
const farmPayload = {
name: validated.name,
ownerId: userId,
joinCode,
address: validated.address,
lat: validated.lat ?? null,
lng: validated.lng ?? null,
stateCode: validated.stateCode,
crops: validated.crops,
acreage: validated.acreage ?? null,
verificationStatus: 'unverified',
createdAt: serverTimestamp(),
}
batch.set(farmRef, farmPayload)
batch.set(memberRef, {
farmId: farmRef.id,
userId,
role: 'owner',
joinedAt: serverTimestamp(),
})
batch.set(searchRef, {
farmId: farmRef.id,
ownerId: userId,
name: validated.name,
address: validated.address,
stateCode: validated.stateCode,
crops: validated.crops,
searchTokens: buildSearchTokens({ name: validated.name, address: validated.address, stateCode: validated.stateCode }),
updatedAt: serverTimestamp(),
})
await batch.commit()
return { id: farmRef.id, ...farmPayload } as Farm
}
export async function joinFarmByCode(userId: string, code: string) {
const db = getFirebaseDb()
const normalized = joinCodeSchema.parse(code)
const farmId = await runTransaction(db, async (transaction) => {
const joinCodeRef = doc(db, 'farmJoinCodes', normalized)
const joinCodeSnapshot = await transaction.get(joinCodeRef)
if (!joinCodeSnapshot.exists()) {
throw new Error('That join code is invalid or expired. Check the code and try again.')
}
const joinData = joinCodeSnapshot.data() as { farmId?: string }
if (!joinData.farmId) {
throw new Error('That join code is invalid or expired. Check the code and try again.')
}
const memberRef = doc(db, 'farmMembers', buildFarmMemberId(joinData.farmId, userId))
const memberSnapshot = await transaction.get(memberRef)
if (memberSnapshot.exists()) {
throw new Error('You already belong to this farm.')
}
transaction.set(memberRef, {
farmId: joinData.farmId,
userId,
role: 'member',
joinCodeUsed: normalized,
joinedAt: serverTimestamp(),
})
return joinData.farmId
})
const farmDoc = await getDoc(doc(db, 'farms', farmId))
if (!farmDoc.exists()) {
throw new Error('That farm is no longer available.')
}
return { id: farmDoc.id, ...farmDoc.data() } as Farm
}
export async function getUserFarmMemberships(userId: string) {
const db = getFirebaseDb()
const membershipSnapshot = await getDocs(query(collection(db, 'farmMembers'), where('userId', '==', userId)))
return membershipSnapshot.docs.map((memberDoc) => ({
id: memberDoc.id,
...memberDoc.data(),
})) as FarmMember[]
}
export async function getUserFarms(userId: string) {
const db = getFirebaseDb()
const memberships = await getUserFarmMemberships(userId)
if (memberships.length === 0) return []
const farms = await Promise.all(
memberships.map(async (membership) => {
const farmDoc = await getDoc(doc(db, 'farms', membership.farmId))
if (!farmDoc.exists()) return null
return { id: farmDoc.id, ...farmDoc.data() } as Farm
})
)
return farms.filter((farm): farm is Farm => farm !== null)
}
export async function getUserFarmSummaries(userId: string) {
const memberships = await getUserFarmMemberships(userId)
const farms = await getUserFarms(userId)
const membershipByFarm = new Map(memberships.map((membership) => [membership.farmId, membership]))
return farms.map((farm) => ({
farm,
membership: membershipByFarm.get(farm.id) ?? null,
}))
}
export async function refreshFarmSearchIndex(userId: string, farm: Farm) {
if (farm.ownerId !== userId) {
throw new Error('Only the farm owner can update farm search settings.')
}
const db = getFirebaseDb()
await setDoc(doc(db, 'farmSearchIndex', farm.id), {
farmId: farm.id,
ownerId: farm.ownerId,
name: farm.name,
address: farm.address,
stateCode: farm.stateCode,
crops: farm.crops,
searchTokens: buildSearchTokens(farm),
updatedAt: serverTimestamp(),
})
}
export async function searchFarmsForAccess(search: string, stateCode: string) {
const db = getFirebaseDb()
const validated = farmSearchSchema.parse({ search, stateCode: stateCode.trim().toUpperCase() })
const token = normalizeSearchText(validated.search).split(' ').find((word) => word.length >= 2)
if (!token) return []
const searchSnapshot = await getDocs(query(
collection(db, 'farmSearchIndex'),
where('stateCode', '==', validated.stateCode),
where('searchTokens', 'array-contains', token.slice(0, 24)),
limit(12)
))
return searchSnapshot.docs.map((searchDoc) => ({
id: searchDoc.id,
...searchDoc.data(),
} as FarmSearchResult)).sort((a, b) => a.name.localeCompare(b.name))
}
export async function requestFarmAccess(userId: string, farm: FarmSearchResult) {
const db = getFirebaseDb()
const requestRef = doc(db, 'farmAccessRequests', buildFarmAccessRequestId(farm.farmId, userId))
const memberRef = doc(db, 'farmMembers', buildFarmMemberId(farm.farmId, userId))
await runTransaction(db, async (transaction) => {
const memberSnapshot = await transaction.get(memberRef)
if (memberSnapshot.exists()) {
throw new Error('You already belong to this farm.')
}
const existingRequest = await transaction.get(requestRef)
if (existingRequest.exists()) {
const request = existingRequest.data() as FarmAccessRequest
if (request.status === 'pending') {
throw new Error('You already have a pending request for this farm.')
}
throw new Error(`Your request for this farm is already ${request.status}.`)
}
transaction.set(requestRef, {
farmId: farm.farmId,
requesterId: userId,
ownerId: farm.ownerId,
status: 'pending',
farmName: farm.name,
farmStateCode: farm.stateCode,
farmAddress: farm.address,
requestedAt: serverTimestamp(),
requestExpiresAt: requestExpiresAt(),
})
})
}
export async function getUserFarmAccessRequests(userId: string) {
const db = getFirebaseDb()
const requestSnapshot = await getDocs(query(collection(db, 'farmAccessRequests'), where('requesterId', '==', userId)))
return requestSnapshot.docs.map((requestDoc) => withDisplayAccessRequestStatus({
id: requestDoc.id,
...requestDoc.data(),
} as FarmAccessRequest))
}
export async function getPendingFarmAccessRequestsForOwner(ownerId: string) {
const db = getFirebaseDb()
const requestSnapshot = await getDocs(query(
collection(db, 'farmAccessRequests'),
where('ownerId', '==', ownerId),
where('status', '==', 'pending')
))
return requestSnapshot.docs
.map((requestDoc) => withDisplayAccessRequestStatus({
id: requestDoc.id,
...requestDoc.data(),
} as FarmAccessRequest))
.filter((request) => request.status === 'pending')
}
export async function approveFarmAccessRequest(ownerId: string, request: FarmAccessRequest) {
const db = getFirebaseDb()
if (!canResolveAccessRequest(ownerId, request)) {
throw new Error('Only the farm owner can approve this request.')
}
const memberRef = doc(db, 'farmMembers', buildFarmMemberId(request.farmId, request.requesterId))
const requestRef = doc(db, 'farmAccessRequests', request.id)
await runTransaction(db, async (transaction) => {
const requestSnapshot = await transaction.get(requestRef)
if (!requestSnapshot.exists()) throw new Error('This request is no longer available.')
const currentRequest = requestSnapshot.data() as FarmAccessRequest
if (currentRequest.status !== 'pending') throw new Error('This request has already been resolved.')
const memberSnapshot = await transaction.get(memberRef)
if (memberSnapshot.exists()) {
transaction.update(requestRef, {
status: 'approved',
resolvedBy: ownerId,
resolvedAt: serverTimestamp(),
})
return
}
transaction.update(requestRef, {
status: 'approved',
resolvedBy: ownerId,
resolvedAt: serverTimestamp(),
})
transaction.set(memberRef, {
farmId: request.farmId,
userId: request.requesterId,
role: 'member',
approvedRequestId: request.id,
joinedAt: serverTimestamp(),
})
})
}
export async function denyFarmAccessRequest(ownerId: string, request: FarmAccessRequest) {
if (!canResolveAccessRequest(ownerId, request)) {
throw new Error('Only the farm owner can deny this request.')
}
const db = getFirebaseDb()
await updateDoc(doc(db, 'farmAccessRequests', request.id), {
status: 'denied',
resolvedBy: ownerId,
resolvedAt: serverTimestamp(),
})
}
export async function leaveFarm(userId: string, membership: FarmMember) {
if (!canLeaveFarm(userId, membership)) {
throw new Error('You can only leave farms connected to your account.')
}
const db = getFirebaseDb()
await deleteDoc(doc(db, 'farmMembers', membership.id))
}
export async function regenerateFarmJoinCode(userId: string, farm: Farm) {
if (farm.ownerId !== userId) {
throw new Error('Only the farm owner can regenerate this join code.')
}
const db = getFirebaseDb()
const batch = writeBatch(db)
const joinCode = await createUniqueJoinCode(batch, farm.id, userId)
batch.update(doc(db, 'farms', farm.id), {
joinCode,
joinCodeUpdatedAt: serverTimestamp(),
})
if (farm.joinCode) {
batch.delete(doc(db, 'farmJoinCodes', farm.joinCode))
}
await batch.commit()
return joinCode
}
export async function saveDiagnosis(input: Omit<Diagnosis, 'id' | 'detectedAt'>) {
const db = getFirebaseDb()
await addDoc(collection(db, 'diagnoses'), {
userId: input.userId,
farmId: input.farmId,
crop: input.crop,
disease: input.disease,
confidence: input.confidence,
detectedAt: serverTimestamp(),
})
}