diff --git a/backend/package.json b/backend/package.json index e68559a6..33e1279f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,6 +7,8 @@ "add_buildings": "ts-node scripts/add_buildings.ts", "add_landlords": "ts-node scripts/add_landlords.ts", "add_reviews": "ts-node scripts/add_reviews_nodups.ts", + "export_apartments": "env-cmd -f ../.env.prod ts-node scripts/export_apartments.ts", + "update_apartments": "env-cmd -f ../.env.prod ts-node scripts/update_apartments_from_csv.ts", "build": "tsc", "tsc": "tsc", "start": "node dist/backend/src/server.js", diff --git a/backend/scripts/export_apartments.ts b/backend/scripts/export_apartments.ts new file mode 100644 index 00000000..3724bd5b --- /dev/null +++ b/backend/scripts/export_apartments.ts @@ -0,0 +1,101 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { db } from '../src/firebase-config'; + +/** + * export_apartments.ts + * + * Exports all apartment documents from Firestore to a CSV file. + * The business team can then edit the CSV and pass it to update_apartments_from_csv.ts. + * + * Usage: + * env-cmd -f ../.env.prod ts-node scripts/export_apartments.ts + * + * Output: + * backend/scripts/apartments_export.csv + */ + +const buildingCollection = db.collection('buildings'); + +// TODO: might have to change header fields or order (talk with business) +const CSV_HEADERS = [ + 'id', + 'name', + 'address', + 'landlordId', + 'numBeds', + 'numBaths', + 'price', + 'area', + 'latitude', + 'longitude', + 'distanceToCampus', +]; + +// escape CSV field values +export const escapeCSVField = (value: unknown): string => { + const str = value === null || value === undefined ? '' : String(value); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +}; + +const exportApartments = async () => { + console.log('Fetching apartments from Firestore...'); + + const snapshot = await buildingCollection.get(); + + if (snapshot.empty) { + console.log('No apartments found in the database.'); + process.exit(0); + } + + console.log(`Found ${snapshot.docs.length} apartments. Writing CSV...`); + + const rows: string[] = [CSV_HEADERS.join(',')]; + + snapshot.docs.forEach((doc) => { + const data = doc.data(); + const row = [ + doc.id, + data.name, + data.address, + data.landlordId, + data.numBeds, + data.numBaths, + data.price, + data.area, + data.latitude, + data.longitude, + data.distanceToCampus, + ] + .map(escapeCSVField) + .join(','); + + rows.push(row); + }); + + const outputPath = path.join(__dirname, 'apartments_export.csv'); + fs.writeFileSync(outputPath, rows.join('\n'), 'utf8'); + + console.log(`Export complete: ${outputPath}`); + console.log(` ${snapshot.docs.length} apartments exported.`); + console.log(''); + console.log('Next steps:'); + console.log(' 1. Open apartments_export.csv in Excel or Google Sheets'); + console.log(' 2. Edit the fields you want to update (do NOT change the id column)'); + console.log(' 3. Save as CSV'); + console.log(' 4. Run: env-cmd -f ../.env.prod ts-node scripts/update_apartments_from_csv.ts'); + + process.exit(0); +}; + +if (require.main === module) { + exportApartments().catch((err) => { + console.error('Export failed:', err); + process.exit(1); + }); +} + +export default exportApartments; diff --git a/backend/scripts/scripts.test.ts b/backend/scripts/scripts.test.ts new file mode 100644 index 00000000..ed9d5993 --- /dev/null +++ b/backend/scripts/scripts.test.ts @@ -0,0 +1,169 @@ +/** + * scripts.test.ts + * + * Unit tests for the pure helper functions in export_apartments.ts + * and update_apartments_from_csv.ts. + * + * Run with: npx jest scripts/scripts.test.ts --forceExit (was getting incompatibility issues in Firebase and node versions but it works with this) + */ + +// Mock firebase-config so importing the scripts doesn't try to connect to Firebase +import { escapeCSVField } from './export_apartments'; +import { parseCSVLine, parseCSV } from './update_apartments_from_csv'; + +jest.mock('../src/firebase-config', () => ({ + db: { + collection: jest.fn(), + }, +})); + +// ─── escapeCSVField ─────────────────────────────────────────────────────────── + +describe('escapeCSVField', () => { + it('returns plain strings unchanged', () => { + expect(escapeCSVField('hello')).toBe('hello'); + }); + + it('wraps values containing a comma in quotes', () => { + expect(escapeCSVField('110 Big Red Ln, Apt 2')).toBe('"110 Big Red Ln, Apt 2"'); + }); + + it('wraps values containing a double-quote in quotes and escapes the inner quote', () => { + expect(escapeCSVField('say "hello"')).toBe('"say ""hello"""'); + }); + + it('wraps values containing a newline in quotes', () => { + expect(escapeCSVField('line1\nline2')).toBe('"line1\nline2"'); + }); + + it('converts numbers to strings', () => { + expect(escapeCSVField(42)).toBe('42'); + expect(escapeCSVField(1.5)).toBe('1.5'); + }); + + it('converts null to an empty string', () => { + expect(escapeCSVField(null)).toBe(''); + }); + + it('converts undefined to an empty string', () => { + expect(escapeCSVField(undefined)).toBe(''); + }); +}); + +// ─── parseCSVLine ───────────────────────────────────────────────────────────── + +describe('parseCSVLine', () => { + it('splits a simple comma-separated line', () => { + expect(parseCSVLine('a,b,c')).toEqual(['a', 'b', 'c']); + }); + + it('handles a quoted field containing a comma', () => { + expect(parseCSVLine('1,"110 Big Red Ln, Apt 2",COLLEGETOWN')).toEqual([ + '1', + '110 Big Red Ln, Apt 2', + 'COLLEGETOWN', + ]); + }); + + it('handles escaped double-quotes inside a quoted field', () => { + expect(parseCSVLine('1,"say ""hello""",3')).toEqual(['1', 'say "hello"', '3']); + }); + + it('trims whitespace around field values', () => { + expect(parseCSVLine(' a , b , c ')).toEqual(['a', 'b', 'c']); + }); + + it('handles empty fields', () => { + expect(parseCSVLine('1,,3')).toEqual(['1', '', '3']); + }); +}); + +// ─── parseCSV ───────────────────────────────────────────────────────────────── + +const VALID_HEADER = + 'id,name,address,landlordId,numBeds,numBaths,price,area,latitude,longitude,distanceToCampus'; + +describe('parseCSV', () => { + it('throws if the file has no data rows', () => { + expect(() => parseCSV(VALID_HEADER)).toThrow('CSV file is empty or has no data rows.'); + }); + + it('throws if a required column is missing', () => { + const csv = 'id,name\n1,Test Apt'; + expect(() => parseCSV(csv)).toThrow('CSV is missing required columns:'); + }); + + it('parses a valid row and returns correct updates', () => { + const csv = [ + VALID_HEADER, + '42,Test Apt,123 College Ave,5,2,1,1200,COLLEGETOWN,42.4534,-76.4735,10', + ].join('\n'); + const { rows, errors } = parseCSV(csv); + expect(errors).toHaveLength(0); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe('42'); + expect(rows[0].updates).toMatchObject({ + name: 'Test Apt', + address: '123 College Ave', + landlordId: '5', + numBeds: 2, + numBaths: 1, + price: 1200, + area: 'COLLEGETOWN', + latitude: 42.4534, + longitude: -76.4735, + distanceToCampus: 10, + }); + }); + + it('accepts area values case-insensitively', () => { + const csv = [VALID_HEADER, '1,Apt,123 St,5,2,1,1000,collegetown,42.0,-76.0,5'].join('\n'); + const { rows, errors } = parseCSV(csv); + expect(errors).toHaveLength(0); + expect(rows[0].updates.area).toBe('COLLEGETOWN'); + }); + + it('returns a validation error for an invalid area', () => { + const csv = [VALID_HEADER, '1,Apt,123 St,5,2,1,1000,INVALID,42.0,-76.0,5'].join('\n'); + const { rows, errors } = parseCSV(csv); + expect(rows).toHaveLength(0); + expect(errors).toHaveLength(1); + expect(errors[0].field).toBe('area'); + }); + + it('returns a validation error for a negative numBeds', () => { + const csv = [VALID_HEADER, '1,Apt,123 St,5,-1,1,1000,COLLEGETOWN,42.0,-76.0,5'].join('\n'); + const { errors } = parseCSV(csv); + expect(errors.some((e) => e.field === 'numBeds')).toBe(true); + }); + + it('returns a validation error for a non-numeric price', () => { + const csv = [VALID_HEADER, '1,Apt,123 St,5,2,1,abc,COLLEGETOWN,42.0,-76.0,5'].join('\n'); + const { errors } = parseCSV(csv); + expect(errors.some((e) => e.field === 'price')).toBe(true); + }); + + it('returns a validation error when id is blank', () => { + const csv = [VALID_HEADER, ',Apt,123 St,5,2,1,1000,COLLEGETOWN,42.0,-76.0,5'].join('\n'); + const { errors } = parseCSV(csv); + expect(errors.some((e) => e.field === 'id')).toBe(true); + }); + + it('skips rows where no fields changed (all blank)', () => { + // Only id is present, everything else is blank — nothing to update + const csv = [VALID_HEADER, '1,,,,,,,,,,'].join('\n'); + const { rows, errors } = parseCSV(csv); + expect(errors).toHaveLength(0); + expect(rows).toHaveLength(0); + }); + + it('collects errors across multiple rows without aborting', () => { + const csv = [ + VALID_HEADER, + '1,Apt,123 St,5,-1,1,1000,COLLEGETOWN,42.0,-76.0,5', // bad numBeds + '2,Apt,456 St,5,2,1,bad,COLLEGETOWN,42.0,-76.0,5', // bad price + ].join('\n'); + const { errors } = parseCSV(csv); + expect(errors.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/backend/scripts/update_apartments_from_csv.ts b/backend/scripts/update_apartments_from_csv.ts new file mode 100644 index 00000000..195ccac9 --- /dev/null +++ b/backend/scripts/update_apartments_from_csv.ts @@ -0,0 +1,313 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { db } from '../src/firebase-config'; + +/** + * update_apartments_from_csv.ts + * + * Reads apartments_export.csv (edited by the business team) and bulk-updates + * the corresponding Firestore documents in the buildings collection. + * + * Only fields that have changed from the original export are updated. + * The id column is used to match rows to Firestore documents — do NOT edit it. + * Fields left blank in the CSV are skipped (not overwritten with empty values). + * + * Editable fields: + * name, address, landlordId, numBeds, numBaths, price, area, + * latitude, longitude, distanceToCampus + * + * Usage: + * env-cmd -f ../.env.prod ts-node scripts/update_apartments_from_csv.ts + * + * Input: + * backend/scripts/apartments_export.csv (edited by business team) + */ + +const buildingCollection = db.collection('buildings'); + +const VALID_AREAS = ['COLLEGETOWN', 'WEST', 'NORTH', 'DOWNTOWN', 'OTHER']; +const VALID_AREAS_SET = new Set(VALID_AREAS); + +type ApartmentUpdate = { + name?: string; + address?: string; + landlordId?: string; + numBeds?: number; + numBaths?: number; + price?: number; + area?: 'COLLEGETOWN' | 'WEST' | 'NORTH' | 'DOWNTOWN' | 'OTHER'; + latitude?: number; + longitude?: number; + distanceToCampus?: number; +}; + +type ParsedRow = { + id: string; + updates: ApartmentUpdate; +}; + +type ValidationError = { + row: number; + id: string; + field: string; + value: string; + reason: string; +}; + +// Parse a raw CSV line into an array of field values +export const parseCSVLine = (line: string): string[] => { + const fields: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i += 1) { + const char = line[i]; + + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + // Escaped quote inside a quoted field + current += '"'; + i += 1; + } else { + inQuotes = !inQuotes; + } + } else if (char === ',' && !inQuotes) { + fields.push(current.trim()); + current = ''; + } else { + current += char; + } + } + + fields.push(current.trim()); + return fields; +}; + +// Parse and validate the CSV file +export const parseCSV = (csvContent: string): { rows: ParsedRow[]; errors: ValidationError[] } => { + const lines = csvContent.split('\n').filter((line) => line.trim() !== ''); + + if (lines.length < 2) { + throw new Error('CSV file is empty or has no data rows.'); + } + + const headers = parseCSVLine(lines[0]); + const expectedHeaders = [ + 'id', + 'name', + 'address', + 'landlordId', + 'numBeds', + 'numBaths', + 'price', + 'area', + 'latitude', + 'longitude', + 'distanceToCampus', + ]; + + // Validate headers + const missingHeaders = expectedHeaders.filter((h) => !headers.includes(h)); + if (missingHeaders.length > 0) { + throw new Error(`CSV is missing required columns: ${missingHeaders.join(', ')}`); + } + + const idx = (name: string) => headers.indexOf(name); + + const rows: ParsedRow[] = []; + const errors: ValidationError[] = []; + + for (let i = 1; i < lines.length; i += 1) { + const fields = parseCSVLine(lines[i]); + const rowNum = i + 1; // (1 index bc of header) + + const id = fields[idx('id')]?.trim(); + if (!id) { + errors.push({ + row: rowNum, + id: '?', + field: 'id', + value: '', + reason: 'id is required and cannot be blank', + }); + } + + const updates: ApartmentUpdate = {}; + let rowHasError = false; + + const addError = (field: string, value: string, reason: string) => { + errors.push({ row: rowNum, id, field, value, reason }); + rowHasError = true; + }; + + // name + const name = fields[idx('name')]?.trim(); + if (name) { + updates.name = name; + } + + // address + const address = fields[idx('address')]?.trim(); + if (address) { + updates.address = address; + } + + // landlordId + const landlordId = fields[idx('landlordId')]?.trim(); + if (landlordId) { + updates.landlordId = landlordId; + } + + // numBeds + const numBedsRaw = fields[idx('numBeds')]?.trim(); + if (numBedsRaw !== '' && numBedsRaw !== undefined) { + const numBeds = Number(numBedsRaw); + if (Number.isNaN(numBeds) || numBeds < 0) { + addError('numBeds', numBedsRaw, 'must be a non-negative number'); + } else { + updates.numBeds = numBeds; + } + } + + // numBaths + const numBathsRaw = fields[idx('numBaths')]?.trim(); + if (numBathsRaw !== '' && numBathsRaw !== undefined) { + const numBaths = Number(numBathsRaw); + if (Number.isNaN(numBaths) || numBaths < 0) { + addError('numBaths', numBathsRaw, 'must be a non-negative number'); + } else { + updates.numBaths = numBaths; + } + } + + // price + const priceRaw = fields[idx('price')]?.trim(); + if (priceRaw !== '' && priceRaw !== undefined) { + const price = Number(priceRaw); + if (Number.isNaN(price) || price < 0) { + addError('price', priceRaw, 'must be a non-negative number'); + } else { + updates.price = price; + } + } + + // area + const areaRaw = fields[idx('area')]?.trim().toUpperCase(); + if (areaRaw) { + if (!VALID_AREAS_SET.has(areaRaw)) { + addError('area', areaRaw, `must be one of: ${VALID_AREAS.join(', ')}`); + } else { + updates.area = areaRaw as ApartmentUpdate['area']; + } + } + + // latitude + const latRaw = fields[idx('latitude')]?.trim(); + if (latRaw !== '' && latRaw !== undefined) { + const latitude = Number(latRaw); + if (Number.isNaN(latitude)) { + addError('latitude', latRaw, 'must be a number'); + } else { + updates.latitude = latitude; + } + } + + // longitude + const lngRaw = fields[idx('longitude')]?.trim(); + if (lngRaw !== '' && lngRaw !== undefined) { + const longitude = Number(lngRaw); + if (Number.isNaN(longitude)) { + addError('longitude', lngRaw, 'must be a number'); + } else { + updates.longitude = longitude; + } + } + + // distanceToCampus + const distRaw = fields[idx('distanceToCampus')]?.trim(); + if (distRaw !== '' && distRaw !== undefined) { + const distanceToCampus = Number(distRaw); + if (Number.isNaN(distanceToCampus) || distanceToCampus < 0) { + addError('distanceToCampus', distRaw, 'must be a non-negative number'); + } else { + updates.distanceToCampus = distanceToCampus; + } + } + + if (!rowHasError && Object.keys(updates).length > 0) { + rows.push({ id, updates }); + } else if (!rowHasError) { + console.log(` Row ${rowNum} (id: ${id}): no changes detected, skipping.`); + } + } + + return { rows, errors }; +}; + +const updateApartments = async () => { + const csvPath = path.join(__dirname, 'apartments_export.csv'); + + if (!fs.existsSync(csvPath)) { + console.error(`CSV file not found at: ${csvPath}`); + console.error(' Run export_apartments.ts first to generate the file.'); + process.exit(1); + } + + const csvContent = fs.readFileSync(csvPath, 'utf8'); + + console.log('Parsing CSV...'); + const { rows, errors } = parseCSV(csvContent); + + // print all validation errors and abort before touching the database + if (errors.length > 0) { + console.error(`\n Found ${errors.length} validation error(s). No changes have been made.\n`); + errors.forEach((e) => { + console.error(` Row ${e.row} (id: ${e.id}) — ${e.field}: "${e.value}" — ${e.reason}`); + }); + console.error('\nFix the errors above in the CSV and re-run the script.'); + process.exit(1); + } + + if (rows.length === 0) { + console.log('No rows to update.'); + process.exit(0); + } + + console.log(`\nUpdating ${rows.length} apartment(s) in Firestore...`); + + let successCount = 0; + let failCount = 0; + + await Promise.all( + rows.map(async ({ id, updates }) => { + try { + const docRef = buildingCollection.doc(id); + const docSnapshot = await docRef.get(); + + if (!docSnapshot.exists) { + console.warn(` id: ${id} — document not found in Firestore, skipping.`); + failCount += 1; + return; + } + + await docRef.update(updates); + console.log(` id: ${id} — updated fields: ${Object.keys(updates).join(', ')}`); + successCount += 1; + } catch (err) { + console.error(` id: ${id} — update failed:`, err); + failCount += 1; + } + }) + ); + + console.log(''); + console.log(`Done. ${successCount} updated, ${failCount} failed/skipped.`); + process.exit(failCount > 0 ? 1 : 0); +}; + +if (require.main === module) { + updateApartments().catch((err) => { + console.error('Script failed:', err); + process.exit(1); + }); +}