'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) { const source = normalizeSearchText(`${farm.name} ${farm.address} ${farm.stateCode}`) const tokens = new Set() 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, 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) { 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(), }) }