diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b34afdb7..c00a693f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 - name: Use Yarn Cache uses: actions/cache@v3 with: diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml index 3a7e6423..99751372 100644 --- a/.github/workflows/firebase-hosting-merge.yml +++ b/.github/workflows/firebase-hosting-merge.yml @@ -12,6 +12,10 @@ jobs: environment: prod steps: - uses: actions/checkout@v2 + - name: Set up Node + uses: actions/setup-node@v3 + with: + node-version: 18 - run: yarn install - run: yarn workspace frontend build env: diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml index 08502fcb..f712251f 100644 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -12,7 +12,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 - run: yarn install - run: yarn workspace frontend build env: diff --git a/.gitignore b/.gitignore index dcb5be09..b2607874 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,11 @@ yarn-error.log* *.code-workspace **/.idea +# Local reference files (not for repo) +TODO.md +DOCS_GUIDE.md +BLOG_TODO.md + # Misc .DS_Store .env diff --git a/backend/package.json b/backend/package.json index e68559a6..4c1f48f4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,6 +7,10 @@ "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", + "export_apartment_names": "ts-node scripts/export_apartment_names.ts", + "init_blogposts": "env-cmd -f ../.env.prod ts-node scripts/init_blogposts_collection.ts", "build": "tsc", "tsc": "tsc", "start": "node dist/backend/src/server.js", diff --git a/backend/scripts/add_buildings.ts b/backend/scripts/add_buildings.ts index a40cf380..11657eb8 100644 --- a/backend/scripts/add_buildings.ts +++ b/backend/scripts/add_buildings.ts @@ -43,13 +43,11 @@ const formatBuilding = ({ name, address, landlordId: landlordId.toString(), - numBaths: 0, - numBeds: 0, + roomTypes: [], // Initialize with empty room types photos: [], area: getAreaType(area), latitude, longitude, - price: 0, distanceToCampus: 0, }); diff --git a/backend/scripts/export_apartment_names.ts b/backend/scripts/export_apartment_names.ts new file mode 100644 index 00000000..89b3cd84 --- /dev/null +++ b/backend/scripts/export_apartment_names.ts @@ -0,0 +1,89 @@ +import axios from 'axios'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Define the structure of the API response based on the AdminPage usage +interface ApartmentData { + buildingData: { + id: string; + name: string; + address: string; + area: string; + numBeds: number; + numBaths: number; + landlordId: string; + photos: string[]; + urlName: string; + latitude: number; + longitude: number; + distanceToCampus: number; + }; + numReviews: number; + company: string; + avgRating: number; + avgPrice: number; +} + +interface ApiResponse { + buildingData: ApartmentData[]; + isEnded: boolean; +} + +/** + * Script to export apartment names to a CSV file + * + * This script fetches all apartment data from the API endpoint used in AdminPage + * and exports just the apartment names to a CSV file. + */ +const exportApartmentNames = async () => { + try { + console.log('Fetching apartment data...'); + + // Use the same API endpoint as AdminPage + const response = await axios.get( + 'http://localhost:8080/api/page-data/home/1000/numReviews' + ); + + if (!response.data || !response.data.buildingData) { + throw new Error('No apartment data received from API'); + } + + const apartments = response.data.buildingData; + console.log(`Found ${apartments.length} apartments`); + + // Extract apartment names + const apartmentNames = apartments.map((apt) => apt.buildingData.name); + + // Create CSV content + const csvHeader = 'Apartment Name\n'; + const csvContent = apartmentNames.map((name) => `"${name}"`).join('\n'); + const fullCsvContent = csvHeader + csvContent; + + // Write to CSV file + const outputPath = path.join(__dirname, 'apartment_names.csv'); + fs.writeFileSync(outputPath, fullCsvContent, 'utf8'); + + console.log(`Successfully exported ${apartmentNames.length} apartment names to: ${outputPath}`); + console.log('First few apartment names:'); + apartmentNames.slice(0, 5).forEach((name, index) => { + console.log(`${index + 1}. ${name}`); + }); + } catch (error) { + console.error('Error exporting apartment names:', error); + + if (axios.isAxiosError(error)) { + if (error.code === 'ECONNREFUSED') { + console.error( + 'Could not connect to the server. Make sure the backend server is running on localhost:3000' + ); + } else { + console.error('API request failed:', error.message); + } + } + + process.exit(1); + } +}; + +// Run the script +exportApartmentNames(); 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/init_blogposts_collection.ts b/backend/scripts/init_blogposts_collection.ts new file mode 100644 index 00000000..28f2e067 --- /dev/null +++ b/backend/scripts/init_blogposts_collection.ts @@ -0,0 +1,61 @@ +/** + * init_blogposts_collection.ts + * + * One-time idempotent script to ensure the `blogposts` Firestore collection exists + * with the correct schema. Creates a sentinel document if the collection is empty. + * + * Safe to run multiple times — never overwrites existing documents. + * + * Usage: ts-node scripts/init_blogposts_collection.ts + */ + +import { db } from '../src/firebase-config'; + +const blogPostCollection = db.collection('blogposts'); + +const initBlogPostsCollection = async (): Promise => { + console.log('Checking blogposts collection...'); + + const snapshot = await blogPostCollection.limit(1).get(); + + if (!snapshot.empty) { + console.log(`Collection already has ${snapshot.size}+ document(s). No initialization needed.`); + return; + } + + console.log('Collection is empty. Creating sentinel document...'); + + const sentinelDoc = blogPostCollection.doc('_init'); + const existing = await sentinelDoc.get(); + + if (existing.exists) { + console.log('Sentinel document already exists. Done.'); + return; + } + + await sentinelDoc.set({ + title: '[INIT] Collection initialized', + content: '', + blurb: '', + date: new Date(), + tags: [], + visibility: 'DELETED', + likes: 0, + saves: 0, + coverImageUrl: '', + userId: null, + }); + + console.log('Sentinel document created at blogposts/_init with visibility=DELETED.'); + console.log('Collection is ready. This sentinel document is safe to delete.'); +}; + +initBlogPostsCollection() + .then(() => { + console.log('Done.'); + process.exit(0); + }) + .catch((err) => { + console.error('Error initializing blogposts collection:', err); + process.exit(1); + }); 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); + }); +} diff --git a/backend/src/app.ts b/backend/src/app.ts index c6c84a95..35300b75 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -2,6 +2,7 @@ import express, { Express, RequestHandler } from 'express'; import cors from 'cors'; import Fuse from 'fuse.js'; import morgan from 'morgan'; +import { randomUUID } from 'crypto'; import { Review, Landlord, @@ -17,6 +18,11 @@ import { QuestionForm, QuestionFormWithId, LocationTravelTimes, + BlogPost, + BlogPostInternal, + BlogPostWithId, + RoomType, + Folder, } from '@common/types/db-types'; // Import Firebase configuration and types import { auth } from 'firebase-admin'; @@ -26,6 +32,7 @@ import axios from 'axios'; import { db, FieldValue, FieldPath } from './firebase-config'; import { Faq } from './firebase-config/types'; import authenticate from './auth'; +import authenticateAdmin, { isAdminEmail } from './authAdmin'; import { admins } from '../../frontend/src/constants/HomeConsts'; // Imports for email sending @@ -34,6 +41,9 @@ import { admins } from '../../frontend/src/constants/HomeConsts'; const cuaptsEmail = process.env.CUAPTS_EMAIL; const cuaptsEmailPassword = process.env.CUAPTS_EMAIL_APP_PASSWORD; +// Google Maps API key +const { REACT_APP_MAPS_API_KEY } = process.env; + // Collections in the Firestore database const reviewCollection = db.collection('reviews'); const landlordCollection = db.collection('landlords'); @@ -42,7 +52,9 @@ const likesCollection = db.collection('likes'); const usersCollection = db.collection('users'); const pendingBuildingsCollection = db.collection('pendingBuildings'); const contactQuestionsCollection = db.collection('contactQuestions'); - +const blogPostCollection = db.collection('blogposts'); +const folderCollection = db.collection('folders'); +const adminWhitelistCollection = db.collection('adminWhitelist'); const travelTimesCollection = db.collection('travelTimes'); // Middleware setup @@ -68,6 +80,288 @@ app.get('/api/faqs', async (_, res) => { res.status(200).send(JSON.stringify(faqs)); }); +/** + * new-blog-post – Creates a new blog post. + * + * @remarks + * This endpoint creates and adds a new blog post into the products database. If necessary data are not given, the endpoint + * will result in an error. Certain fields are defaulted to constant values. + * + * @route POST /api/new-blog-post + * + * @status + * - 201: Successfully created new blog post. + * - 401: Error due to unauthorized access or authentication issues. + */ +app.post('/api/new-blog-post', authenticate, async (req, res) => { + if (!req.user) throw new Error('Not authenticated'); + const realUserId = req.user.uid; + try { + const doc = blogPostCollection.doc(); + const blogPost = req.body as BlogPost; + if ( + blogPost.content === '' || + blogPost.blurb === '' || + blogPost.title === '' || + !blogPost.coverImageUrl || + !blogPost.tags + ) { + res.status(401).send('Error: missing fields'); + } + doc.set({ + ...blogPost, + date: new Date(), + likes: 0, + saves: 0, + userId: realUserId, + }); + return res.status(201).send(doc.id); + } catch (err) { + console.error(err); + return res.status(401).send('Error'); + } +}); + +/** + * delete-blog-post/:blogPostId – Deletes a specified blog post. + * + * @remarks + * This endpoint deletes a specified blog post from its ID. The post is removed from the products database. + * If no blog post is found or the user is not authorized to delete the review, then an error is thrown. + * + * @route PUT /api/delete-blog-post/:blogPostId + * + * @status + * - 200: Successfully deleted the specified blog post. + * - 404: Blog post could not be found from ID. + * - 401: Error due to unauthorized access or authentication issues. + */ +app.put('/api/delete-blog-post/:blogPostId', authenticate, async (req, res) => { + if (!req.user) throw new Error('Not authenticated'); + const { blogPostId } = req.params; // Extract the blog post document ID from the request parameters + const { email } = req.user; + // Check if the user is an admin or the creator of the blog post + const blogPostDoc = blogPostCollection.doc(blogPostId); + const blogPostData = (await blogPostDoc.get()).data(); + if (!blogPostData) { + res.status(404).send('Blog Post not found'); + return; + } + if (!(email && admins.includes(email))) { + res.status(403).send('Unauthorized'); + return; + } + try { + // Update the status of the blog post document to 'DELETED' + await blogPostCollection.doc(blogPostId).update({ visibility: 'ARCHIVED' }); + // Send a success response + res.status(200).send('Success'); + } catch (err) { + // Handle any errors that may occur during the deletion process + console.log(err); + res.status(401).send('Error'); + } +}); + +/** + * edit-blog-post/:blogPostId – Edits a specified blog post. + * + * @remarks + * This endpoint edits a specified blog post from its ID. The post is edited from the products database. + * If no blog post is found or the user is not authorized to edit the review, then an error is thrown. + * + * @route POST /api/edit-blog-post/:blogPostId + * + * @status + * - 201: Successfully edited the specified blog post. + * - 404: Blog post could not be found from ID. + * - 401: Error due to unauthorized access or authentication issues. + */ +app.post('/api/edit-blog-post/:blogPostId', authenticate, async (req, res) => { + // if (!req.user) { + // throw new Error('not authenticated'); + // } + const { blogPostId } = req.params; + // const { email } = req.user; + try { + const blogPostDoc = blogPostCollection.doc(blogPostId); // specific doc for the id + const blogPostData = (await blogPostDoc.get()).data(); + if (!blogPostData) { + res.status(404).send('Blog Post not found'); + return; + } + // if (!(email && admins.includes(email))) { + // res.status(401).send('Error: user is not an admin. Not authorized'); + // return; + // } + const updatedBlogPost = req.body as BlogPost; + if (updatedBlogPost.content === '' || updatedBlogPost.title === '') { + res.status(401).send('Error: missing fields'); + } + blogPostDoc + .update({ + ...updatedBlogPost, + date: new Date(updatedBlogPost.date), + }) + .then(() => { + res.status(201).send(blogPostId); + }); + } catch (err) { + console.error(err); + res.status(401).send('Error'); + } +}); + +/** + * blog-post-by-id/:blogPostId – Gets a specified blog post. + * + * @remarks + * This endpoint gets a specified blog post from its ID. The post is grabbed from the products database. + * If no blog post is found, then an error is thrown. + * + * @route GET /api/blog-post-by-id/:blogPostId + * + * @status + * - 200: Successfully edited the specified blog post. + * - 404: Blog post could not be found from ID. + * - 401: Error due to unauthorized access or authentication issues. + */ +app.get('/api/blog-post-by-id/:blogPostId', async (req, res) => { + const { blogPostId } = req.params; + + try { + const blogPostDoc = await blogPostCollection.doc(blogPostId).get(); + if (!blogPostDoc.exists) { + res.status(404).send('Blog Post not found'); + return; + } + + const data = blogPostDoc.data(); + + let blogPost: BlogPostInternal; + if (data?.date && typeof (data.date as any).toDate === 'function') { + // Firestore Timestamp -> Date + blogPost = { ...data, date: data.date.toDate() } as BlogPostInternal; + } else { + // Already a Date or missing + blogPost = { ...data } as BlogPostInternal; + } + + const blogPostWithId = { ...blogPost, id: blogPostDoc.id } as BlogPostWithId; + res.status(200).json(blogPostWithId); + } catch (err) { + console.error('Error retrieving Blog Post', err); + res.status(500).send('Error retrieving Blog Post'); + } +}); + +/** + * blog-post/like/:userId – Fetches blog posts liked by a user. + * + * @remarks + * This endpoint retrieves blog posts that a user has liked. + * + * @route GET /api/blog-post/like/:userId + * + * @status + * - 200: Successfully retrieved the blog posts. + * - 401: Error due to unauthorized access or authentication issues. + */ +app.get('/api/blog-post/like/:userId', authenticate, async (req, res) => { + if (!req.user) { + throw new Error('not authenticated'); + } + const realUserId = req.user.uid; + const { userId } = req.params; + if (userId !== realUserId) { + res.status(401).send("Error: user is not authorized to access another user's likes"); + return; + } + const likesDoc = await likesCollection.doc(userId).get(); + + if (likesDoc.exists) { + const data = likesDoc.data(); + if (data) { + const blogPostIds = data.blogPosts; + const matchingBlogPosts: BlogPostWithId[] = []; + if (blogPostIds.length > 0) { + const query = blogPostCollection.where(FieldPath.documentId(), 'in', blogPostIds); + const querySnapshot = await query.get(); + querySnapshot.forEach((doc) => { + const data = doc.data(); + const blogPostData = { ...data, date: data.date.toDate() }; + matchingBlogPosts.push({ ...blogPostData, id: doc.id } as BlogPostWithId); + }); + } + res.status(200).send(JSON.stringify(matchingBlogPosts)); + return; + } + } + + res.status(200).send(JSON.stringify([])); +}); + +/** + * blog-post/like/:userId – Fetches blog posts saved by a user. + * + * @remarks + * This endpoint retrieves blog posts that a user has saved. + * + * @route GET /api/blog-post/save/:userId + * + * @status + * - 200: Successfully retrieved the blog posts. + * - 401: Error due to unauthorized access or authentication issues. + */ +app.get('/api/blog-post/save/:userId', authenticate, async (req, res) => { + if (!req.user) { + throw new Error('not authenticated'); + } + const realUserId = req.user.uid; + const { userId } = req.params; + if (userId !== realUserId) { + res.status(401).send("Error: user is not authorized to access another user's saves"); + return; + } + const savesDoc = await likesCollection.doc(realUserId).get(); + + if (savesDoc.exists) { + const data = savesDoc.data(); + if (data) { + const blogPostIds = Object.keys(data); + const matchingBlogPosts: BlogPostWithId[] = []; + if (blogPostIds.length > 0) { + const query = blogPostCollection.where(FieldPath.documentId(), 'in', blogPostIds); + const querySnapshot = await query.get(); + querySnapshot.forEach((doc) => { + const data = doc.data(); + const blogPostData = { ...data, date: data.date.toDate() }; + matchingBlogPosts.push({ ...blogPostData, id: doc.id } as BlogPostWithId); + }); + } + res.status(200).send(JSON.stringify(matchingBlogPosts)); + return; + } + } + + res.status(200).send(JSON.stringify([])); +}); + +/** + * blog-posts – Gets all visible blog posts for the Advice page. + * + * @route GET /api/blog-posts + */ +app.get('/api/blog-posts', async (req, res) => { + const blogPostDocs = (await blogPostCollection.where('visibility', '==', 'ACTIVATED').get()).docs; + const blogPosts: BlogPost[] = blogPostDocs.map((doc) => { + const data = doc.data(); + const blogPost = { ...data } as BlogPostInternal; + return { ...blogPost, id: doc.id } as BlogPostWithId; + }); + res.status(200).send(JSON.stringify(blogPosts)); +}); + // API endpoint to post a new review app.post('/api/new-review', authenticate, async (req, res) => { try { @@ -219,7 +513,7 @@ app.get('/api/review/like/:userId', authenticate, async (req, res) => { }); /** - * Takes in the location type in the URL and returns the number of reviews made forr that location + * Takes in the location type in the URL and returns the number of reviews made for that location */ app.get('/api/review/:location/count', async (req, res) => { const { location } = req.params; @@ -506,78 +800,148 @@ app.get('/api/search-with-query-and-filters', async (req, res) => { const apts = req.app.get('apts'); const aptsWithType: ApartmentWithId[] = apts; - // Start with text search if query is provided - let filteredResults: ApartmentWithId[] = []; + // STEP 1: Apply text search first + let baseResults: ApartmentWithId[] = []; if (query && query.trim() !== '') { const options = { keys: ['name', 'address'], }; const fuse = new Fuse(aptsWithType, options); const searchResults = fuse.search(query); - filteredResults = searchResults.map((result) => result.item); + baseResults = searchResults.map((result) => result.item); } else { // If no query, start with all apartments - filteredResults = aptsWithType; + baseResults = aptsWithType; } - // Apply location filter if provided - if (locations && locations.trim() !== '') { + // STEP 2: Count active filter categories + const hasLocation = locations && locations.trim() !== ''; + const hasPrice = minPrice !== null || maxPrice !== null; + const hasBedBath = (bedrooms !== null && bedrooms > 0) || (bathrooms !== null && bathrooms > 0); + const activeFilterCount = [hasLocation, hasPrice, hasBedBath].filter(Boolean).length; + + // Helper: Filter by location only + const filterByLocation = (apts: ApartmentWithId[]): ApartmentWithId[] => { + if (!hasLocation) return []; const locationArray = locations.split(',').map((loc) => loc.toUpperCase()); - filteredResults = filteredResults.filter((apt) => - locationArray.includes(apt.area ? apt.area.toUpperCase() : '') - ); - } + return apts.filter((apt) => locationArray.includes(apt.area ? apt.area.toUpperCase() : '')); + }; - // TODO: Right now we disable the price filter because of lack of data - // Apply price range filters - // if (minPrice !== null) { - // filteredResults = filteredResults.filter((apt) => apt.price >= minPrice); - // } + // Helper: Filter by price only + const filterByPrice = (apts: ApartmentWithId[]): ApartmentWithId[] => { + if (!hasPrice) return []; + return apts.filter((apt) => { + if (!apt.roomTypes || apt.roomTypes.length === 0) return false; + return apt.roomTypes.some((roomType) => { + if (minPrice !== null && roomType.price < minPrice) return false; + if (maxPrice !== null && roomType.price > maxPrice) return false; + return true; + }); + }); + }; - // if (maxPrice !== null) { - // filteredResults = filteredResults.filter((apt) => apt.price <= maxPrice); - // } + // Helper: Filter by bed/bath only + const filterByBedBath = (apts: ApartmentWithId[]): ApartmentWithId[] => { + if (!hasBedBath) return []; + return apts.filter((apt) => { + if (!apt.roomTypes || apt.roomTypes.length === 0) return false; + return apt.roomTypes.some((roomType) => { + if (bedrooms !== null && bedrooms > 0 && roomType.beds !== bedrooms) return false; + if (bathrooms !== null && bathrooms > 0 && roomType.baths !== bathrooms) return false; + return true; + }); + }); + }; - // Apply bedroom filter - // TODO: Right now we disable the bedroom filter because of lack of data - // if (bedrooms !== null && bedrooms > 0) { - // filteredResults = filteredResults.filter( - // (apt) => apt.numBeds !== null && apt.numBeds >= bedrooms - // ); - // } + // Helper: Filter by ALL criteria (main results) + const filterByAll = (apts: ApartmentWithId[]): ApartmentWithId[] => { + let filtered = apts; - // // Apply bathroom filter - // if (bathrooms !== null && bathrooms > 0) { - // filteredResults = filteredResults.filter( - // (apt) => apt.numBaths !== null && apt.numBaths >= bathrooms - // ); - // } + // Apply location filter + if (hasLocation) { + filtered = filterByLocation(filtered); + } + + // Apply price filter + if (hasPrice) { + filtered = filterByPrice(filtered); + } + + // Apply bed/bath filter + if (hasBedBath) { + filtered = filterByBedBath(filtered); + } + + return filtered; + }; - // Process the filtered results through pageData to include reviews, ratings, etc. - let enrichedResults = await pageData(filteredResults); + // STEP 3: Calculate results based on active filter count + let mainResults: ApartmentWithId[] = []; + let additionalLocation: ApartmentWithId[] = []; + let additionalPrice: ApartmentWithId[] = []; + let additionalBedBath: ApartmentWithId[] = []; + + if (activeFilterCount === 0) { + // No filters: return all base results as main + mainResults = baseResults; + } else if (activeFilterCount === 1) { + // 1 filter: main results only, no additional sections + mainResults = filterByAll(baseResults); + } else { + // 2+ filters: calculate main + additional sections + // Main: matches ALL filters + mainResults = filterByAll(baseResults); + const mainIds = new Set(mainResults.map((apt) => apt.id)); + + // Additional: each individual filter (excluding main results) + if (hasLocation) { + additionalLocation = filterByLocation(baseResults).filter((apt) => !mainIds.has(apt.id)); + } + if (hasPrice) { + additionalPrice = filterByPrice(baseResults).filter((apt) => !mainIds.has(apt.id)); + } + if (hasBedBath) { + additionalBedBath = filterByBedBath(baseResults).filter((apt) => !mainIds.has(apt.id)); + } + } + + // STEP 4: Enrich all result sections with pageData + const enrichMain = await pageData(mainResults); + const enrichLocation = await pageData(additionalLocation); + const enrichPrice = await pageData(additionalPrice); + const enrichBedBath = await pageData(additionalBedBath); - // If size is specified and less than available, slice the results - if (size && size > 0 && enrichedResults.length > size) { + // STEP 5: Apply sorting and size limit to main results + let finalMain = enrichMain; + if (size && size > 0 && finalMain.length > size) { console.log('endpoint sortBy', sortBy); switch (sortBy) { case 'numReviews': - enrichedResults.sort((a, b) => b.numReviews - a.numReviews); + finalMain.sort((a, b) => b.numReviews - a.numReviews); break; case 'avgRating': - enrichedResults.sort((a, b) => b.avgRating - a.avgRating); + finalMain.sort((a, b) => b.avgRating - a.avgRating); break; case 'distanceToCampus': - enrichedResults.sort( + finalMain.sort( (a, b) => a.buildingData.distanceToCampus - b.buildingData.distanceToCampus ); break; default: break; } - enrichedResults = enrichedResults.slice(0, size); + finalMain = finalMain.slice(0, size); } - res.status(200).send(JSON.stringify(enrichedResults)); + // STEP 6: Return structured response + const response = { + main: finalMain, + additionalLocation: enrichLocation, + additionalPrice: enrichPrice, + additionalBedBath: enrichBedBath, + }; + + res.status(200).send(JSON.stringify(response)); } catch (err) { console.error(err); res.status(400).send('Error'); @@ -784,10 +1148,10 @@ const likeHandler = t.update(reviewRef, { likes: FieldValue.increment(likeChange) }); } }); - res.status(200).send(JSON.stringify({ result: 'Success' })); + return res.status(200).send(JSON.stringify({ result: 'Success' })); } catch (err) { console.error(err); - res.status(400).send('Error'); + return res.status(400).send('Error'); } }; @@ -826,6 +1190,10 @@ app.put('/api/delete-review/:reviewId', authenticate, async (req, res) => { /** * Handles saving or removing saved apartments for a user in the database. * + * @deprecated This endpoint is deprecated and will be removed in future versions. + * Use the new folder-based endpoints (`POST /api/folders/:folderId/apartments` + * and `DELETE /api/folders/:folderId/apartments/:apartmentId`) instead. + * * @param add - If true, the apartment is added to the user's saved list. If false, it is removed. */ const saveApartmentHandler = @@ -861,16 +1229,17 @@ const saveApartmentHandler = t.update(userRef, { apartments: userApartments }); }); - res.status(200).send(JSON.stringify({ result: 'Success' })); + return res.status(200).send(JSON.stringify({ result: 'Success' })); } catch (err) { console.error(err); - res.status(400).send('Error'); + return res.status(400).send('Error'); } }; /** * Handles saving or removing saved landlords for a user in the database. * + * @deprecated This endpoint is deprecated and will be removed in future versions. * @param add - If true, the landlord is added to the user's saved list. If false, it is removed. */ const saveLandlordHandler = @@ -906,14 +1275,18 @@ const saveLandlordHandler = t.update(userRef, { landlords: userLandlords }); }); - res.status(200).send(JSON.stringify({ result: 'Success' })); + return res.status(200).send(JSON.stringify({ result: 'Success' })); } catch (err) { console.error(err); - res.status(400).send('Error'); + return res.status(400).send('Error'); } }; -// Function to check if a user has an apartment saved or not +/** + * Function to check if a user has an apartment saved or not + * @deprecated This endpoint is deprecated and will be removed in future versions. + */ + const checkSavedApartment = (): RequestHandler => async (req, res) => { try { if (!req.user) throw new Error('Not authenticated'); @@ -924,25 +1297,28 @@ const checkSavedApartment = (): RequestHandler => async (req, res) => { if (!userRef) { throw new Error('User data not found'); } - await db.runTransaction(async (t) => { + + const isSaved = await db.runTransaction(async (t) => { const userDoc = await t.get(userRef); if (!userDoc.exists) { t.set(userRef, { apartments: [] }); + return false; } const userApartments = userDoc.data()?.apartments || []; - if (userApartments.includes(apartmentId)) { - res.status(200).send(JSON.stringify({ result: true })); - } else { - res.status(200).send(JSON.stringify({ result: false })); - } + return userApartments.includes(apartmentId); }); + + return res.status(200).send(JSON.stringify({ result: isSaved })); } catch (err) { console.error(err); - res.status(400).send('Error'); + return res.status(400).send('Error'); } }; -// Function to check if a user has an landlord saved or not +/** + * Function to check if a user has an landlord saved or not + * @deprecated This endpoint is deprecated and will be removed in future versions. + */ const checkSavedLandlord = (): RequestHandler => async (req, res) => { try { if (!req.user) throw new Error('Not authenticated'); @@ -953,45 +1329,63 @@ const checkSavedLandlord = (): RequestHandler => async (req, res) => { if (!userRef) { throw new Error('User data not found'); } - await db.runTransaction(async (t) => { + + const isSaved = await db.runTransaction(async (t) => { const userDoc = await t.get(userRef); if (!userDoc.exists) { t.set(userRef, { landlords: [] }); + return false; } const userLandlords = userDoc.data()?.landlords || []; - if (userLandlords.includes(landlordId)) { - res.status(200).send(JSON.stringify({ result: true })); - } else { - res.status(200).send(JSON.stringify({ result: false })); - } + return userLandlords.includes(landlordId); }); + + return res.status(200).send(JSON.stringify({ result: isSaved })); } catch (err) { console.error(err); - res.status(400).send('Error'); + return res.status(400).send('Error'); } }; -// Endpoint for checking if an apartment is saved by a user +/** + * + * Endpoint for checking if an apartment is saved by a user + * @deprecated This endpoint is deprecated and will be removed in future versions. + */ app.post('/api/check-saved-apartment', authenticate, checkSavedApartment()); // This endpoint uses authentication middleware to ensure that the user is logged in. // The checkSavedApartment() function is then called to check if a specific apartment is saved by the user. -// Endpoint for checking if a landlord is saved by a user +/** + * + * Endpoint for checking if an landlord is saved by a user + * @deprecated This endpoint is deprecated and will be removed in future versions. + */ app.post('/api/check-saved-landlord', authenticate, checkSavedLandlord()); // Similar to the above endpoint, this one checks if a specific landlord is saved by the user. // It also uses authentication to ensure that the request is made by a logged-in user. -// Endpoint for adding a saved apartment for a user +/** + * Endpoint for adding a saved apartment for a user + * @deprecated This endpoint is deprecated and will be removed in future versions. + */ app.post('/api/add-saved-apartment', authenticate, saveApartmentHandler(true)); // This endpoint allows authenticated users to add an apartment to their list of saved apartments. // The saveApartmentHandler function is used with a parameter of 'true' to signify adding a save. -// Endpoint for removing a saved apartment for a user +/** + * Endpoint for removing a saved apartment for a user + * @deprecated This endpoint is deprecated and will be removed in future versions. + */ app.post('/api/remove-saved-apartment', authenticate, saveApartmentHandler(false)); // This endpoint allows authenticated users to remove an apartment from their list of saved apartments. // The saveApartmentHandler function is used with a parameter of 'false' to signify removing a save. -// Endpoint to get a list of saved apartments for a user +/** + * Endpoint to get a list of saved apartments for a user + * + * @deprecated This endpoint is deprecated and will be removed in future versions. + */ app.get('/api/saved-apartments', authenticate, async (req, res) => { if (!req.user) throw new Error('Not authenticated'); const { uid } = req.user; // Extracting user ID from the request @@ -1012,12 +1406,70 @@ app.get('/api/saved-apartments', authenticate, async (req, res) => { return res.status(200).send(data); }); -// Endpoints for adding and removing saved landlords for a user +/** + * Endpoint for adding a saved landlord for a user + * @deprecated This endpoint is deprecated and will be removed in future versions. + */ app.post('/api/add-saved-landlord', authenticate, saveLandlordHandler(true)); + +/** + * Endpoint for removing a saved landlord for a user + * @deprecated This endpoint is deprecated and will be removed in future versions. + */ app.post('/api/remove-saved-landlord', authenticate, saveLandlordHandler(false)); // These endpoints allow for adding and removing landlords to/from a user's saved list. // Both endpoints use the saveLandlordHandler function with appropriate boolean parameters. +app.post('/api/send-email-to-landlord', authenticate, async (req, res) => { + if (!req.user) throw new Error('Not authenticated'); + + const userId = req.user.uid; + const userRecord = await auth().getUser(userId); + const userEmail = userRecord?.email; + const { landlordEmail, message, subject } = req.body; + + try { + if (!cuaptsEmail || !cuaptsEmailPassword) { + throw new Error('Host email or password not found'); + } + if (!userEmail) { + throw new Error('User email not found'); + } + + const transporter = nodemailer.createTransport({ + service: 'gmail', + host: 'smtp.gmail.com', + port: 465, + secure: true, + auth: { + type: 'login', + user: cuaptsEmail, + pass: cuaptsEmailPassword, + }, + }); + + const mailOptions = { + from: { name: 'The CUApts Team', address: cuaptsEmail }, + to: landlordEmail, + subject, + text: message, + replyTo: userEmail, + }; + + transporter.sendMail(mailOptions, (error, info) => { + if (error) { + console.log('Error sending email:', error); + return res.status(500).send('Error sending email'); + } + console.log('Email sent:', info.response); + return res.status(200).send('Email sent successfully'); + }); + } catch (err) { + console.log(err); + res.status(500).send('Error'); + } +}); + /** * update-review-status * @@ -1303,24 +1755,547 @@ app.post('/api/add-pending-building', authenticate, async (req, res) => { }); /** - * Update Pending Building Status - Updates the status of a pending building report. + * Update Apartment Information - Updates the information of an existing apartment. * * @remarks - * This endpoint allows authenticated users to update the status of a pending building report. - * The status can be changed to either 'PENDING', 'COMPLETED', or 'DELETED'. + * This endpoint allows admins to update apartment information including name, address, + * landlord, amenities, photos, and other details. Only admins can access this endpoint. * - * @route PUT /api/update-pending-building-status/:buildingId/:newStatus + * @route PUT /api/admin/update-apartment/:apartmentId * - * @input {string} req.params.buildingId - The ID of the pending building report - * @input {string} req.params.newStatus - The new status to set ('PENDING', 'COMPLETED', or 'DELETED') + * @input {string} req.params.apartmentId - The ID of the apartment to update + * @input {Partial} req.body - The updated apartment information * * @status - * - 200: Successfully updated the building status - * - 400: Invalid status type provided - * - 404: Building report not found - * - 500: Server error while updating status + * - 200: Successfully updated apartment information + * - 400: Invalid apartment data or missing required fields + * - 401: Authentication failed + * - 403: Unauthorized - Admin access required + * - 404: Apartment not found + * - 500: Server error while updating apartment */ -app.put( +app.put('/api/admin/update-apartment/:apartmentId', authenticate, async (req, res) => { + if (!req.user) throw new Error('Not authenticated'); + + const { email } = req.user; + const isAdmin = email && admins.includes(email); + + if (!isAdmin) { + res.status(403).send('Unauthorized: Admin access required'); + return; + } + + try { + const { apartmentId } = req.params; + const updatedApartmentData = req.body as Partial; + + // Check if apartment exists + const apartmentDoc = buildingsCollection.doc(apartmentId); + const apartmentSnapshot = await apartmentDoc.get(); + + if (!apartmentSnapshot.exists) { + res.status(404).send('Apartment not found'); + return; + } + + // Validate required fields if they're being updated + if (updatedApartmentData.name !== undefined && updatedApartmentData.name === '') { + res.status(400).send('Error: Apartment name cannot be empty'); + return; + } + + if (updatedApartmentData.address !== undefined && updatedApartmentData.address === '') { + res.status(400).send('Error: Apartment address cannot be empty'); + return; + } + + // Validate and process roomTypes if provided + if (updatedApartmentData.roomTypes !== undefined) { + const roomTypes = updatedApartmentData.roomTypes as RoomType[]; + + // Generate UUIDs for room types without IDs and validate + const processedRoomTypes: RoomType[] = []; + const seen = new Set(); + + // Validate each room type + const validationErrors = roomTypes + .map((rt: RoomType): string | null => { + // Validate beds, baths, price + if (!Number.isInteger(rt.beds) || rt.beds < 1) { + return 'Error: Beds must be an integer >= 1'; + } + if (!Number.isInteger(rt.baths) || rt.baths < 1) { + return 'Error: Baths must be an integer >= 1'; + } + if (!Number.isInteger(rt.price) || rt.price < 1) { + return 'Error: Price must be an integer >= 1'; + } + + // Check for duplicates using (beds, baths, price) combination + const key = `${rt.beds}-${rt.baths}-${rt.price}`; + if (seen.has(key)) { + return 'Duplicate room type exists'; + } + seen.add(key); + + // Generate UUID if not provided + const id = rt.id && rt.id.trim() !== '' ? rt.id : randomUUID(); + + processedRoomTypes.push({ + id, + beds: rt.beds, + baths: rt.baths, + price: rt.price, + }); + + return null; + }) + .filter((error: string | null): error is string => error !== null); + + if (validationErrors.length > 0) { + res.status(400).send(validationErrors[0]); + return; + } + + // Create updated data with processed room types + const dataToUpdate = { + ...updatedApartmentData, + roomTypes: processedRoomTypes, + }; + + // Update the apartment document + await apartmentDoc.update(dataToUpdate); + } else { + // Update the apartment document without roomTypes changes + await apartmentDoc.update(updatedApartmentData); + } + + // Fetch the updated apartment to return with generated UUIDs + const updatedSnapshot = await apartmentDoc.get(); + const updatedApartment = { id: updatedSnapshot.id, ...updatedSnapshot.data() }; + + res.status(200).json({ + message: 'Apartment updated successfully', + apartment: updatedApartment, + }); + } catch (err) { + console.error(err); + res.status(500).send('Error updating apartment'); + } +}); + +/** + * geocodeAddress – Converts an address to latitude and longitude coordinates using Google Geocoding API. + * + * @remarks + * Makes an HTTP request to the Google Geocoding API to convert an address string into + * precise latitude and longitude coordinates. + * + * @param {string} address - The address to geocode + * @return {Promise<{latitude: number, longitude: number}>} - Object containing latitude and longitude + */ +async function geocodeAddress(address: string): Promise<{ latitude: number; longitude: number }> { + const response = await axios.get( + `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent( + address + )}&key=${REACT_APP_MAPS_API_KEY}` + ); + + if (response.data.status !== 'OK' || !response.data.results.length) { + throw new Error('Geocoding failed: Invalid address or no results found'); + } + + const { location } = response.data.results[0].geometry; + return { + latitude: location.lat, + longitude: location.lng, + }; +} + +/** + * Add New Apartment - Creates a new apartment with duplicate checking and distance calculation. + * + * @remarks + * This endpoint allows admins to create new apartments. It includes a multi-step process: + * 1. Validates input data and checks for existing apartments at the same coordinates + * 2. Calculates latitude/longitude and distance to campus using Google Maps API + * 3. Returns preliminary data for admin confirmation + * 4. Creates the apartment in the database after confirmation + * + * @route POST /api/admin/add-apartment + * + * @input {object} req.body - Apartment creation data containing: + * - name: string (required) + * - address: string (required) + * - landlordId: string (required) + * - numBaths: number (optional) + * - numBeds: number (optional) + * - photos: string[] (optional) + * - area: 'COLLEGETOWN' | 'WEST' | 'NORTH' | 'DOWNTOWN' | 'OTHER' (required) + * - confirm: boolean (optional) - if true, creates the apartment; if false, returns preliminary data + * + * @status + * - 200: Successfully created apartment (when confirm=true) + * - 201: Preliminary data returned for confirmation (when confirm=false) + * - 400: Invalid apartment data, missing required fields, or duplicate apartment found + * - 401: Authentication failed + * - 403: Unauthorized - Admin access required + * - 500: Server error while processing request + */ +app.post('/api/admin/add-apartment', authenticate, async (req, res) => { + if (!req.user) throw new Error('Not authenticated'); + + const { email } = req.user; + const isAdmin = email && admins.includes(email); + + if (!isAdmin) { + res.status(403).send('Unauthorized: Admin access required'); + return; + } + + try { + const { + name, + address, + landlordId, + roomTypes = [], + photos = [], + area, + confirm = false, + } = req.body; + + // Validate required fields + if (!name || !address || !landlordId || !area) { + res.status(400).send('Error: Missing required fields (name, address, landlordId, area)'); + return; + } + + // Validate area is one of the allowed values + const validAreas = ['COLLEGETOWN', 'WEST', 'NORTH', 'DOWNTOWN', 'OTHER']; + if (!validAreas.includes(area)) { + res + .status(400) + .send('Error: Invalid area. Must be one of: COLLEGETOWN, WEST, NORTH, DOWNTOWN, OTHER'); + return; + } + + // Validate and process roomTypes if provided + const processedRoomTypes: RoomType[] = []; + if (roomTypes && roomTypes.length > 0) { + const seen = new Set(); + + // Validate each room type + const validationErrors = roomTypes + .map((rt: RoomType): string | null => { + // Validate beds, baths, price + if (!Number.isInteger(rt.beds) || rt.beds < 1) { + return 'Error: Beds must be an integer >= 1'; + } + if (!Number.isInteger(rt.baths) || rt.baths < 1) { + return 'Error: Baths must be an integer >= 1'; + } + if (!Number.isInteger(rt.price) || rt.price < 1) { + return 'Error: Price must be an integer >= 1'; + } + + // Check for duplicates using (beds, baths, price) combination + const key = `${rt.beds}-${rt.baths}-${rt.price}`; + if (seen.has(key)) { + return 'Duplicate room type exists'; + } + seen.add(key); + + // Generate UUID for each room type + processedRoomTypes.push({ + id: randomUUID(), + beds: rt.beds, + baths: rt.baths, + price: rt.price, + }); + + return null; + }) + .filter((error: string | null): error is string => error !== null); + + if (validationErrors.length > 0) { + res.status(400).send(validationErrors[0]); + return; + } + } + + // Check if landlord exists + const landlordDoc = landlordCollection.doc(landlordId); + const landlordSnapshot = await landlordDoc.get(); + if (!landlordSnapshot.exists) { + res.status(400).send('Error: Landlord not found'); + return; + } + + // Geocode the address to get coordinates + const coordinates = await geocodeAddress(address); + const { latitude, longitude } = coordinates; + + // Calculate travel times using the coordinates + const { protocol } = req; + const host = req.get('host'); + const baseUrl = `${protocol}://${host}`; + + const travelTimesResponse = await axios.post(`${baseUrl}/api/calculate-travel-times`, { + origin: `${latitude},${longitude}`, + }); + + const travelTimes = travelTimesResponse.data; + const distanceToCampus = travelTimes.hoPlazaWalking; + + // Check for existing apartments at the same coordinates (with small tolerance) + const tolerance = 0.0001; // Approximately 11 meters + const existingApartments = await buildingsCollection + .where('latitude', '>=', latitude - tolerance) + .where('latitude', '<=', latitude + tolerance) + .where('longitude', '>=', longitude - tolerance) + .where('longitude', '<=', longitude + tolerance) + .get(); + + if (!existingApartments.empty) { + res.status(400).send('Error: An apartment already exists at this location'); + return; + } + + // Prepare apartment data + const apartmentData: Apartment = { + name, + address, + landlordId, + roomTypes: processedRoomTypes as readonly RoomType[], + photos, + area, + latitude, + longitude, + distanceToCampus, + }; + + if (!confirm) { + // Return preliminary data for admin confirmation + res.status(201).json({ + message: 'Preliminary data calculated. Please confirm to create apartment.', + apartmentData, + travelTimes, + coordinates: { latitude, longitude }, + }); + return; + } + + // Create the apartment in the database + const apartmentDoc = buildingsCollection.doc(); + await apartmentDoc.set(apartmentData); + + // Update landlord's properties list + const landlordData = landlordSnapshot.data(); + const updatedProperties = [...(landlordData?.properties || []), apartmentDoc.id]; + await landlordDoc.update({ properties: updatedProperties }); + + // Store travel times for the new apartment + await travelTimesCollection.doc(apartmentDoc.id).set(travelTimes); + + res.status(200).json({ + message: 'Apartment created successfully', + apartmentId: apartmentDoc.id, + apartmentData, + }); + } catch (err) { + console.error(err); + res.status(500).send('Error creating apartment'); + } +}); + +/** + * Migrate All Apartments Schema - Migrates all apartments from old schema to new room types schema. + * + * @remarks + * This endpoint performs a one-time migration of all apartments in the database: + * 1. Initializes roomTypes as empty array [] + * 2. Deletes old fields: numBeds, numBaths, price + * 3. Processes apartments in batches of 100 (3 batches total for ~300 apartments) + * 4. Returns summary of migration success/failures + * + * @route POST /api/admin/migrate-all-apartments-schema + * + * @input {object} req.body - Migration options containing: + * - dryRun: boolean (optional) - if true, returns count without making changes + * + * @status + * - 200: Migration completed successfully (returns summary) + * - 401: Authentication failed + * - 403: Unauthorized - Admin access required + * - 500: Server error during migration + */ +app.post('/api/admin/migrate-all-apartments-schema', authenticate, async (req, res) => { + if (!req.user) throw new Error('Not authenticated'); + + const { email } = req.user; + const isAdmin = email && admins.includes(email); + + if (!isAdmin) { + res.status(403).send('Unauthorized: Admin access required'); + return; + } + + try { + const { dryRun = false } = req.body; + + // Fetch all apartments + const apartmentsSnapshot = await buildingsCollection.get(); + const totalApartments = apartmentsSnapshot.size; + + if (dryRun) { + // Dry run: just return count and preview + const sampleApartments = apartmentsSnapshot.docs.slice(0, 3).map((doc) => { + const data = doc.data(); + return { + id: doc.id, + name: data.name, + hasOldSchema: 'numBeds' in data || 'numBaths' in data || 'price' in data, + currentData: { numBeds: data.numBeds, numBaths: data.numBaths, price: data.price }, + }; + }); + + res.status(200).json({ + dryRun: true, + totalApartments, + message: `Would migrate ${totalApartments} apartments`, + sampleApartments, + }); + return; + } + + // Actual migration + const startTime = Date.now(); + const batchSize = 100; + const batches = Math.ceil(totalApartments / batchSize); + + let migrated = 0; + let failed = 0; + const errors: string[] = []; + + console.log(`Starting migration of ${totalApartments} apartments in ${batches} batches...`); + + // Process in batches + const batchIndices = Array.from({ length: batches }, (_, i) => i); + + await batchIndices.reduce(async (promise, batchIndex) => { + await promise; + + const batchStartTime = Date.now(); + const start = batchIndex * batchSize; + const end = Math.min(start + batchSize, totalApartments); + const batchDocs = apartmentsSnapshot.docs.slice(start, end); + + console.log( + `Processing batch ${batchIndex + 1}/${batches} (${batchDocs.length} apartments)...` + ); + + // Process batch + const batch = db.batch(); + + batchDocs.forEach((doc) => { + try { + const data = doc.data(); + + // Check for corrupted data + if (!data.name || !data.address) { + errors.push(`Apartment ${doc.id}: Missing required fields (name or address)`); + failed += 1; + return; + } + + // Create migrated apartment data + // If old schema fields exist, preserve them as a single RoomType entry + const legacyRoomType = + data.numBeds != null && data.numBaths != null && data.price != null + ? [ + { + id: `${doc.id}_legacy`, + beds: data.numBeds, + baths: data.numBaths, + price: data.price, + }, + ] + : []; + + const migratedData: Record = { + name: data.name, + address: data.address, + landlordId: data.landlordId || null, + roomTypes: legacyRoomType, + photos: data.photos || [], + area: data.area || 'OTHER', + latitude: data.latitude || 0, + longitude: data.longitude || 0, + distanceToCampus: data.distanceToCampus || 0, + }; + + // Use batch.set to replace the document entirely (deletes old fields) + batch.set(doc.ref, migratedData); + migrated += 1; + } catch (err) { + const errorMsg = `Apartment ${doc.id}: ${ + err instanceof Error ? err.message : 'Unknown error' + }`; + errors.push(errorMsg); + failed += 1; + console.error(errorMsg); + } + }); + + // Commit batch + await batch.commit(); + + const batchDuration = Date.now() - batchStartTime; + console.log(`Batch ${batchIndex + 1}/${batches} completed in ${batchDuration}ms`); + }, Promise.resolve()); + + const totalDuration = Date.now() - startTime; + + const summary = { + success: true, + totalApartments, + migrated, + failed, + errors, + durationMs: totalDuration, + message: `Migration completed: ${migrated} migrated, ${failed} failed`, + }; + + console.log('Migration summary:', summary); + + res.status(200).json(summary); + } catch (err) { + console.error('Migration error:', err); + res + .status(500) + .send(`Error during migration: ${err instanceof Error ? err.message : 'Unknown error'}`); + } +}); + +/** + * Update Pending Building Status - Updates the status of a pending building report. + * + * @remarks + * This endpoint allows authenticated users to update the status of a pending building report. + * The status can be changed to either 'PENDING', 'COMPLETED', or 'DELETED'. + * + * @route PUT /api/update-pending-building-status/:buildingId/:newStatus + * + * @input {string} req.params.buildingId - The ID of the pending building report + * @input {string} req.params.newStatus - The new status to set ('PENDING', 'COMPLETED', or 'DELETED') + * + * @status + * - 200: Successfully updated the building status + * - 400: Invalid status type provided + * - 404: Building report not found + * - 500: Server error while updating status + */ +app.put( '/api/update-pending-building-status/:buildingId/:newStatus', authenticate, async (req, res) => { @@ -1427,7 +2402,6 @@ app.put( } ); -const { REACT_APP_MAPS_API_KEY } = process.env; const LANDMARKS = { eng_quad: '42.4445,-76.4836', // Duffield Hall ag_quad: '42.4489,-76.4780', // Mann Library @@ -1733,4 +2707,477 @@ app.post('/api/create-distance-to-campus', async (req, res) => { } }); +/** + * Add Folder - Creates a new folder assigned to a specific user. + * + * @route POST /api/folders + * + * @input {string} req.body - The name of the new folder to be created + * + * @status + * - 201: Successfully created the folder + * - 400: Folder name is missing or invalid + * - 500: Error creating folder + */ +app.post('/api/folders', authenticate, async (req, res) => { + try { + if (!req.user) throw new Error('Not authenticated'); + const { uid } = req.user; + const { folderName } = req.body; + if (!folderName || folderName.trim() === '') { + return res.status(400).send('Folder name is required'); + } + const newFolderRef = folderCollection.doc(); + + // Create a new folder document + await newFolderRef.set({ + name: folderName, + userId: uid, + createdAt: new Date(), + }); + + return res.status(201).json({ id: newFolderRef.id, name: folderName }); + } catch (err) { + console.error(err); + return res.status(500).send('Error creating folder'); + } +}); + +/** + * Get Folders - Fetches all folders assigned to a specific user. + * + * @route GET /api/folders + * + * @status + * - 200: Successfully retrieved folders + * - 500: Error fetching folders + */ +app.get('/api/folders', authenticate, async (req, res) => { + try { + if (!req.user) throw new Error('Not authenticated'); + const { uid } = req.user; + // Fetch all folders for this user + const folderSnapshot = await folderCollection.where('userId', '==', uid).get(); + + const folders = folderSnapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })); + + return res.status(200).json(folders); + } catch (err) { + console.error(err); + return res.status(500).send('Error fetching folders'); + } +}); + +/** Get Folder By ID - Fetches a specific folder by ID. + * + * @route GET /api/folders/:folderId + * + * @input {string} req.params.folderId - The ID of the folder to be fetched + * + * @status + * - 200: Successfully retrieved folder + * - 403: Unauthorized to access this folder (not the owner) + * - 404: Folder not found + * - 500: Error fetching folder + */ +app.get('/api/folders/:folderId', authenticate, async (req, res) => { + try { + if (!req.user) throw new Error('Not authenticated'); + const { uid } = req.user; + const { folderId } = req.params; + + const folderRef = folderCollection.doc(folderId); + const folderDoc = await folderRef.get(); + + if (!folderDoc.exists) { + return res.status(404).send('Folder not found'); + } + + if (folderDoc.data()?.userId !== uid) { + return res.status(403).send('Unauthorized to access this folder'); + } + + return res.status(200).json({ id: folderDoc.id, ...folderDoc.data() }); + } catch (err) { + console.error(err); + return res.status(500).send('Error fetching folder'); + } +}); + +/** + * Delete Folder - Deletes a folder by ID. + * + * @route DELETE /api/folders/:folderId + * + * @input {string} req.params.folderId - The ID of the folder to be deleted + * + * @status + * - 200: Successfully deleted folder + * - 403: Unauthorized to delete this folder (not the owner) + * - 404: Folder not found + * - 500: Error deleting folder + */ +app.delete('/api/folders/:folderId', authenticate, async (req, res) => { + try { + if (!req.user) throw new Error('Not authenticated'); + const { uid } = req.user; + const { folderId } = req.params; + + const folderRef = folderCollection.doc(folderId); + const folderDoc = await folderRef.get(); + + if (!folderDoc.exists) { + return res.status(404).send('Folder not found'); + } + + if (folderDoc.data()?.userId !== uid) { + return res.status(403).send('Unauthorized to delete this folder'); + } + + await folderRef.delete(); + return res.status(200).send('Folder deleted successfully'); + } catch (err) { + console.error(err); + return res.status(500).send('Error deleting folder'); + } +}); + +/** + * Rename Folder - Renames a folder by ID. + * + * @route PUT /api/folders/:folderId + * + * @input {string} req.params.folderId - The ID of the folder to be renamed + * + * @status + * - 200: Successfully renamed folder + * - 403: Unauthorized to rename this folder (not the owner) + * - 404: Folder not found + * - 500: Error renaming folder + */ +app.put('/api/folders/:folderId', authenticate, async (req, res) => { + try { + if (!req.user) throw new Error('Not authenticated'); + const { uid } = req.user; + const { folderId } = req.params; + const { newName } = req.body; + + const folderRef = folderCollection.doc(folderId); + const folderDoc = await folderRef.get(); + + if (!folderDoc.exists) { + return res.status(404).send('Folder not found'); + } + + if (folderDoc.data()?.userId !== uid) { + return res.status(403).send('Unauthorized to rename this folder'); + } + + await folderRef.update({ name: newName }); + return res.status(200).send('Folder renamed successfully'); + } catch (err) { + console.error(err); + return res.status(500).send('Error renaming folder'); + } +}); + +/** + * Add Apartment - Adds an apartment to a folder. + * + * @route POST /api/folders/:folderId/apartments + * + * @input {string} req.body - The id of the apartment to be added + * @input {string} req.params.folderId - The ID of the folder to add the apartment to + * + * @status + * - 200: Successfully added apartment to folder + * - 403: Unauthorized to modify this folder (not the owner) + * - 404: Folder not found + * - 400: Apartment already in folder + * - 500: Error adding apartment to folder + */ +app.post('/api/folders/:folderId/apartments', authenticate, async (req, res) => { + try { + if (!req.user) throw new Error('Not authenticated'); + const { uid } = req.user; + const { folderId } = req.params; + const { aptId } = req.body; + + const folderRef = folderCollection.doc(folderId); + const folderDoc = await folderRef.get(); + + if (!folderDoc.exists) { + return res.status(404).send('Folder not found'); + } + + if (folderDoc.data()?.userId !== uid) { + return res.status(403).send('Unauthorized to modify this folder'); + } + + const apartments = folderDoc.data()?.apartments || []; + if (apartments.includes(aptId)) { + return res.status(400).send('Apartment already in folder'); + } + + apartments.push(aptId); + await folderRef.update({ apartments }); + return res.status(200).send('Apartment added to folder successfully'); + } catch (err) { + console.error(err); + return res.status(500).send('Error adding apartment to folder'); + } +}); + +/** + * Remove Apartment - Removes an apartment from a folder. + * + * @route DELETE /api/folders/:folderId/apartments/:apartmentId + * + * @input {string} req.body - The id of the apartment to be removed + * @input {string} req.params.folderId - The ID of the folder to remove the apartment from + * + * @status + * - 200: Successfully removed apartment from folder + * - 403: Unauthorized to modify this folder (not the owner) + * - 404: Folder not found + * - 500: Error removing apartment from folder + */ +app.delete('/api/folders/:folderId/apartments/:apartmentId', authenticate, async (req, res) => { + try { + if (!req.user) throw new Error('Not authenticated'); + const { uid } = req.user; + const { folderId, apartmentId } = req.params; + + const folderRef = folderCollection.doc(folderId); + const folderDoc = await folderRef.get(); + + if (!folderDoc.exists) { + return res.status(404).send('Folder not found'); + } + + if (folderDoc.data()?.userId !== uid) { + return res.status(403).send('Unauthorized to modify this folder'); + } + + let apartments = folderDoc.data()?.apartments || []; + apartments = apartments.filter((id: string) => id !== apartmentId); + await folderRef.update({ apartments }); + return res.status(200).send('Apartment removed from folder successfully'); + } catch (err) { + console.error(err); + return res.status(500).send('Error removing apartment from folder'); + } +}); + +/** + * Get Apartments in Folder - Retrieves all apartments in a specific folder. + * + * @route GET /api/folders/:folderId/apartments + * + * @input {string} req.params - The folderId of the folder to get apartments from + * + * @status + * - 200: Successfully retrieved apartments from folder + * - 403: Unauthorized to access this folder (not the owner) + * - 404: Folder not found + * - 500: Error fetching apartments from folder + */ +app.get('/api/folders/:folderId/apartments', authenticate, async (req, res) => { + try { + if (!req.user) throw new Error('Not authenticated'); + const { uid } = req.user; + const { folderId } = req.params; + + const folderRef = folderCollection.doc(folderId); + const folderDoc = await folderRef.get(); + + if (!folderDoc.exists) { + return res.status(404).send('Folder not found'); + } + + if (folderDoc.data()?.userId !== uid) { + return res.status(403).send('Unauthorized to access this folder'); + } + + const apartments = folderDoc.data()?.apartments || []; + const aptsArr = await Promise.all( + apartments.map(async (id: string) => { + const snapshot = await buildingsCollection.doc(id).get(); + if (!snapshot.exists) { + console.warn(`Apartment ${id} not found`); + return null; + } + return { id, ...snapshot.data() }; + }) + ); + + // Filter out any null values from non-existent apartments + const validApartments = aptsArr.filter((apt) => apt !== null); + const enrichedResults = await pageData(validApartments); + return res.status(200).json(enrichedResults); + } catch (err) { + console.error(err); + return res.status(500).send('Error fetching apartments from folder'); + } +}); + +/** + * Is Admin – Returns whether the authenticated user has admin privileges. + * + * @route GET /api/is-admin + * + * @status + * - 200: { isAdmin: boolean } + * - 401: Not authenticated + */ +app.get('/api/is-admin', authenticate, async (req, res) => { + try { + if (!req.user?.email) return res.status(200).json({ isAdmin: false }); + const adminStatus = await isAdminEmail(req.user.email); + return res.status(200).json({ isAdmin: adminStatus }); + } catch (err) { + console.error(err); + return res.status(200).json({ isAdmin: false }); + } +}); + +/** + * Get Admin Whitelist – Returns all emails in the Firestore adminWhitelist plus hardcoded superadmins. + * + * @route GET /api/admin/whitelist + * + * @status + * - 200: { superadmins: string[], whitelist: { id: string, email: string }[] } + * - 403: Not an admin + */ +app.get('/api/admin/whitelist', authenticateAdmin, async (req, res) => { + try { + const snapshot = await adminWhitelistCollection.orderBy('addedAt', 'asc').get(); + const whitelist = snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() })); + return res.status(200).json({ superadmins: admins, whitelist }); + } catch (err) { + console.error(err); + return res.status(500).send('Error fetching admin whitelist'); + } +}); + +/** + * Add Admin – Adds an email to the Firestore adminWhitelist. + * + * @route POST /api/admin/whitelist + * + * @input {string} req.body.email – The Cornell email to add. + * + * @status + * - 201: Admin added successfully + * - 400: Missing or invalid email + * - 409: Email already in whitelist or superadmin list + * - 403: Not an admin + */ +app.post('/api/admin/whitelist', authenticateAdmin, async (req, res) => { + try { + const { email } = req.body; + if (!email || typeof email !== 'string' || !email.endsWith('@cornell.edu')) { + return res.status(400).send('Valid Cornell email required'); + } + if (admins.includes(email)) { + return res.status(409).send('Email is already a superadmin'); + } + const existing = await adminWhitelistCollection.where('email', '==', email).limit(1).get(); + if (!existing.empty) { + return res.status(409).send('Email is already in the whitelist'); + } + const docRef = await adminWhitelistCollection.add({ + email, + addedAt: new Date(), + addedBy: req.user!.email, + }); + return res.status(201).json({ id: docRef.id, email }); + } catch (err) { + console.error(err); + return res.status(500).send('Error adding admin'); + } +}); + +/** + * Remove Admin – Removes an email from the Firestore adminWhitelist. + * + * @route DELETE /api/admin/whitelist/:email + * + * @input {string} req.params.email – URL-encoded email to remove. + * + * @status + * - 200: Admin removed + * - 400: Email is a superadmin (cannot remove via UI) + * - 404: Email not found in whitelist + * - 403: Not an admin + */ +app.delete('/api/admin/whitelist/:email', authenticateAdmin, async (req, res) => { + try { + const email = decodeURIComponent(req.params.email); + if (admins.includes(email)) { + return res.status(400).send('Cannot remove a superadmin via this endpoint'); + } + const snapshot = await adminWhitelistCollection.where('email', '==', email).limit(1).get(); + if (snapshot.empty) { + return res.status(404).send('Email not found in whitelist'); + } + await snapshot.docs[0].ref.delete(); + return res.status(200).send('Admin removed successfully'); + } catch (err) { + console.error(err); + return res.status(500).send('Error removing admin'); + } +}); + +/** + * Init Collections – Ensures required Firestore collections exist with correct schema. + * Idempotent: safe to run multiple times, never overwrites existing documents. + * + * @route POST /api/admin/init-collections + * + * @status + * - 200: Collections initialized (or already existed) + * - 500: Error during initialization + */ +app.post('/api/admin/init-collections', authenticate, async (req, res) => { + try { + const results: string[] = []; + + // Initialize blogposts collection + const blogSnapshot = await blogPostCollection.limit(1).get(); + if (blogSnapshot.empty) { + const sentinelRef = blogPostCollection.doc('_init'); + const existing = await sentinelRef.get(); + if (!existing.exists) { + await sentinelRef.set({ + title: '[INIT] Collection initialized', + content: '', + blurb: '', + date: new Date(), + tags: [], + visibility: 'DELETED', + likes: 0, + saves: 0, + coverImageUrl: '', + userId: null, + }); + results.push('blogposts: created sentinel document'); + } else { + results.push('blogposts: sentinel already exists'); + } + } else { + results.push('blogposts: collection already has documents'); + } + + return res.status(200).json({ message: 'Initialization complete', results }); + } catch (err) { + console.error(err); + return res.status(500).send('Error initializing collections'); + } +}); + export default app; diff --git a/backend/src/authAdmin.ts b/backend/src/authAdmin.ts new file mode 100644 index 00000000..32c85377 --- /dev/null +++ b/backend/src/authAdmin.ts @@ -0,0 +1,69 @@ +import { RequestHandler } from 'express'; +import { auth, db } from './firebase-config'; +import { admins } from '../../frontend/src/constants/HomeConsts'; + +const adminWhitelistCollection = db.collection('adminWhitelist'); + +/** + * Checks whether an email is a recognized admin. + * + * @remarks + * Returns true if the email is in the hardcoded `admins` array (superadmins) + * OR in the Firestore `adminWhitelist` collection (dynamically added admins). + * + * @param {string} email – The email address to check. + * @returns {Promise} – Whether the email has admin privileges. + */ +export const isAdminEmail = async (email: string): Promise => { + if (admins.includes(email)) return true; + const snapshot = await adminWhitelistCollection.where('email', '==', email).limit(1).get(); + return !snapshot.empty; +}; + +/** + * Middleware to authenticate admin API requests. + * + * @remarks + * Extends the base `authenticate` middleware by additionally verifying that the + * requesting user is a recognized admin (either hardcoded superadmin or in + * Firestore `adminWhitelist`). + * + * @returns 401 if token is invalid, 403 if user is not an admin, otherwise calls next. + */ +const authenticateAdmin: RequestHandler = async (req, res, next) => { + try { + const { authorization } = req.headers; + + if (!authorization) { + res.status(401).send({ error: 'Header not found' }); + return; + } + + const [bearer, token] = authorization.split(' '); + + if (bearer !== 'Bearer') { + res.status(401).send({ error: 'Invalid token syntax' }); + return; + } + + const user = await auth.verifyIdToken(token); + + if (!user.email?.endsWith('@cornell.edu')) { + res.status(401).send({ error: 'Invalid domain' }); + return; + } + + const adminStatus = await isAdminEmail(user.email); + if (!adminStatus) { + res.status(403).send({ error: 'Not authorized' }); + return; + } + + req.user = user; + next(); + } catch (e) { + res.status(401).send({ error: 'Authentication Error' }); + } +}; + +export default authenticateAdmin; diff --git a/common/types/db-types.ts b/common/types/db-types.ts index 20463657..58258b86 100644 --- a/common/types/db-types.ts +++ b/common/types/db-types.ts @@ -33,10 +33,27 @@ export type Review = { readonly reports?: readonly ReportEntry[]; }; +export type BlogPost = { + readonly content: string; + readonly blurb: string; + readonly date: Date; + readonly likes?: number; + readonly tags: string[]; + readonly title: string; + readonly userId?: string | null; + readonly visibility: string; + readonly saves: number; + readonly coverImageUrl: string; +}; + export type ReviewWithId = Review & Id; +export type BlogPostWithId = BlogPost & Id; + export type ReviewInternal = Review & {}; +export type BlogPostInternal = BlogPost & {}; + export type Landlord = { readonly name: string; readonly contact: string | null; @@ -51,17 +68,22 @@ export type Landlord = { export type LandlordWithId = Landlord & Id; export type LandlordWithLabel = LandlordWithId & { readonly label: 'LANDLORD' }; +export type RoomType = { + readonly id: string; // UUID generated by backend + readonly beds: number; // >= 1, integer + readonly baths: number; // >= 1, integer + readonly price: number; // >= 1, integer (in dollars) +}; + export type Apartment = { readonly name: string; readonly address: string; // may change to placeID for Google Maps integration readonly landlordId: string | null; - readonly numBaths: number | null; - readonly numBeds: number | null; + readonly roomTypes: readonly RoomType[]; // can be empty array readonly photos: readonly string[]; // can be empty readonly area: 'COLLEGETOWN' | 'WEST' | 'NORTH' | 'DOWNTOWN' | 'OTHER'; readonly latitude: number; readonly longitude: number; - readonly price: number; readonly distanceToCampus: number; // walking distance to ho plaza in minutes }; @@ -87,6 +109,7 @@ export type CantFindApartmentForm = { readonly address: string; readonly photos: readonly string[]; readonly userId?: string | null; + readonly review: string; }; export type CantFindApartmentFormWithId = CantFindApartmentForm & Id; @@ -100,3 +123,9 @@ export type QuestionForm = { }; export type QuestionFormWithId = QuestionForm & Id; + +export type Folder = { + readonly name: string; + readonly userId: string; + readonly apartments: string[]; +}; diff --git a/frontend/.eslintrc b/frontend/.eslintrc index c3647487..ddc95ee7 100644 --- a/frontend/.eslintrc +++ b/frontend/.eslintrc @@ -1,7 +1,14 @@ { "extends": [ - "../.eslintrc", "react-app", "react-app/jest" - ] + ], + "settings": { + "import/resolver": { + "node": { + "extensions": [".mjs", ".js", ".json", ".ts", ".tsx"], + "paths": ["common", "frontend/src", "backend/src"] + } + } + } } \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 6c3bf983..59a05c44 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", + "@tinymce/tinymce-react": "3.13.1", "@types/jest": "^26.0.15", "@types/node": "^12.0.0", "@types/react": "^16.9.53", @@ -28,7 +29,7 @@ "react-router-dom": "^5.2.0", "react-scripts": "4.0.0", "sass": "^1.45.0", - "typescript": "~4.0.5", + "typescript": "~4.9.5", "web-vitals": "^0.2.4" }, "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 66dd0aa0..44d080b2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,10 +2,12 @@ import React, { ReactElement, useEffect, useState } from 'react'; import './App.scss'; import { Route, Switch, useLocation } from 'react-router-dom'; import HomePage from './pages/HomePage'; -import FAQPage from './pages/FAQPage'; +import BlogPostPage from './pages/BlogPostPage'; +import BlogPostDetailPage from './pages/BlogPostDetailPage'; import ReviewPage from './pages/ReviewPage'; import LandlordPage from './pages/LandlordPage'; import ProfilePage from './pages/ProfilePage'; +import FolderDetailPage from './pages/FolderDetailPage'; import BookmarksPage from './pages/BookmarksPage'; import { ThemeProvider } from '@material-ui/core'; import { createTheme } from '@material-ui/core/styles'; @@ -73,9 +75,9 @@ const home: NavbarButton = { href: '/', }; -const faq: NavbarButton = { - label: 'FAQ', - href: '/faq', +const blogs: NavbarButton = { + label: 'Advice', + href: '/blogs', }; export type CardData = { @@ -91,13 +93,15 @@ export type LocationCardData = { location: string; }; -const headersData = [home, faq]; +const headersData = [home, blogs]; hotjar.initialize(HJID, HJSV); const App = (): ReactElement => { const [user, setUser] = useState(null); + const [isAdminUser, setIsAdminUser] = useState(false); const { pathname } = useLocation(); + useEffect(() => { const setData = async () => { await axios.post('/api/set-data'); @@ -105,6 +109,26 @@ const App = (): ReactElement => { setData(); }, []); + // Check admin status whenever user changes — checks both hardcoded list and Firestore whitelist + useEffect(() => { + const checkAdmin = async () => { + if (!user) { + setIsAdminUser(false); + return; + } + try { + const token = await user.getIdToken(); + const response = await axios.get('/api/is-admin', { + headers: { Authorization: `Bearer ${token}` }, + }); + setIsAdminUser(response.data.isAdmin === true); + } catch { + setIsAdminUser(false); + } + }; + checkAdmin(); + }, [user]); + return ( @@ -113,7 +137,10 @@ const App = (): ReactElement => { } /> - + + + + { path="/profile" component={() => } /> + + + + } @@ -146,10 +177,9 @@ const App = (): ReactElement => { path="/search" component={() => } /> - {isAdmin(user) && } + {(isAdmin(user) || isAdminUser) && } - {pathname !== '/faq' &&