From f04f43de4ac0c3d7b4eda46671271b5d6288df4d Mon Sep 17 00:00:00 2001 From: Nigel Tatem Date: Sun, 15 Feb 2026 23:07:32 -0500 Subject: [PATCH] Add apartment tag CRUD endpoints --- backend/package.json | 5 +- backend/src/app.ts | 119 +++++++++++++++++++++++++++ backend/src/firebase-config/index.ts | 12 ++- backend/src/server.test.ts | 96 ++++++++++++++++++++- backend/test.sh | 2 +- common/types/db-types.ts | 7 ++ yarn.lock | 11 ++- 7 files changed, 244 insertions(+), 8 deletions(-) diff --git a/backend/package.json b/backend/package.json index e68559a6..c235bd13 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,8 +35,11 @@ "devDependencies": { "@types/cors": "^2.8.10", "@types/express": "^4.17.11", + "@types/jest": "^26.0.15", "@types/morgan": "^1.9.2", "@types/supertest": "^2.0.11", - "eslint-import-resolver-typescript": "^2.5.0" + "eslint-import-resolver-typescript": "^2.5.0", + "jest": "^26.6.0", + "ts-jest": "^26.5.3" } } \ No newline at end of file diff --git a/backend/src/app.ts b/backend/src/app.ts index c6c84a95..70f16ebf 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -12,6 +12,7 @@ import { LandlordWithLabel, ApartmentWithLabel, ApartmentWithId, + TagWithId, CantFindApartmentForm, CantFindApartmentFormWithId, QuestionForm, @@ -38,6 +39,7 @@ const cuaptsEmailPassword = process.env.CUAPTS_EMAIL_APP_PASSWORD; const reviewCollection = db.collection('reviews'); const landlordCollection = db.collection('landlords'); const buildingsCollection = db.collection('buildings'); +const tagsCollection = db.collection('tags'); const likesCollection = db.collection('likes'); const usersCollection = db.collection('users'); const pendingBuildingsCollection = db.collection('pendingBuildings'); @@ -68,6 +70,34 @@ app.get('/api/faqs', async (_, res) => { res.status(200).send(JSON.stringify(faqs)); }); +// API endpoint to create a new tag (or return existing) +app.post('/api/tags', async (req, res) => { + try { + const { name } = req.body as { name?: unknown }; + if (typeof name !== 'string' || name.trim().length === 0) { + res.status(400).send('Error: invalid tag name'); + return; + } + + const trimmedName = name.trim(); + const normalizedName = trimmedName.toLowerCase(); + + const existing = await tagsCollection.where('normalizedName', '==', normalizedName).get(); + if (!existing.empty) { + const doc = existing.docs[0]; + res.status(200).send(JSON.stringify({ id: doc.id, name: doc.data()?.name } as TagWithId)); + return; + } + + const doc = tagsCollection.doc(); + await doc.set({ name: trimmedName, normalizedName }); + res.status(200).send(JSON.stringify({ id: doc.id, name: trimmedName } as TagWithId)); + } catch (err) { + console.error(err); + res.status(400).send('Error'); + } +}); + // API endpoint to post a new review app.post('/api/new-review', authenticate, async (req, res) => { try { @@ -274,6 +304,95 @@ app.get('/api/apts/:ids', async (req, res) => { } }); +// API endpoint to get tags for a specific apartment +app.get('/api/apts/:id/tags', async (req, res) => { + try { + const { id } = req.params; + const snapshot = await buildingsCollection.doc(id).get(); + if (!snapshot.exists) { + res.status(400).send('Invalid id'); + return; + } + + const tags = snapshot.data()?.tags as readonly string[] | undefined; + if (!tags || tags.length === 0) { + res.status(200).send(JSON.stringify([])); + return; + } + + const tagDocs = await Promise.all( + tags.map(async (tagId) => [tagId, await tagsCollection.doc(tagId).get()] as const) + ); + const result: TagWithId[] = tagDocs + .filter(([, doc]) => doc.exists) + .map(([tagId, doc]) => ({ id: tagId, name: doc.data()?.name } as TagWithId)) + .filter((tag) => typeof tag.name === 'string'); + + res.status(200).send(JSON.stringify(result)); + } catch (err) { + console.error(err); + res.status(400).send('Error'); + } +}); + +// API endpoint to add a tag to a specific apartment +app.post('/api/apts/:id/tags/:tagId', async (req, res) => { + try { + const { id, tagId } = req.params; + const buildingRef = buildingsCollection.doc(id); + const buildingDoc = await buildingRef.get(); + if (!buildingDoc.exists) { + res.status(400).send('Invalid id'); + return; + } + + const tagDoc = await tagsCollection.doc(tagId).get(); + if (!tagDoc.exists) { + res.status(400).send('Invalid tag id'); + return; + } + + const existingTags = (buildingDoc.data()?.tags as readonly string[] | undefined) || []; + if (existingTags.includes(tagId)) { + res.status(200).send(JSON.stringify([...existingTags])); + return; + } + + const updatedTags = [...existingTags, tagId]; + await buildingRef.update({ tags: updatedTags }); + res.status(200).send(JSON.stringify(updatedTags)); + } catch (err) { + console.error(err); + res.status(400).send('Error'); + } +}); + +// API endpoint to remove a tag from a specific apartment +app.delete('/api/apts/:id/tags/:tagId', async (req, res) => { + try { + const { id, tagId } = req.params; + const buildingRef = buildingsCollection.doc(id); + const buildingDoc = await buildingRef.get(); + if (!buildingDoc.exists) { + res.status(400).send('Invalid id'); + return; + } + + const existingTags = (buildingDoc.data()?.tags as readonly string[] | undefined) || []; + if (!existingTags.includes(tagId)) { + res.status(200).send(JSON.stringify([...existingTags])); + return; + } + + const updatedTags = existingTags.filter((t) => t !== tagId); + await buildingRef.update({ tags: updatedTags }); + res.status(200).send(JSON.stringify(updatedTags)); + } catch (err) { + console.error(err); + res.status(400).send('Error'); + } +}); + // API endpoint to get a specific landlord by ID app.get('/api/landlord/:id', async (req, res) => { try { diff --git a/backend/src/firebase-config/index.ts b/backend/src/firebase-config/index.ts index f12d6494..a26ac352 100644 --- a/backend/src/firebase-config/index.ts +++ b/backend/src/firebase-config/index.ts @@ -10,10 +10,14 @@ const hydrateServiceAccount = (): admin.ServiceAccount => { return { projectId, clientEmail, privateKey }; }; -admin.initializeApp({ - credential: admin.credential.cert(hydrateServiceAccount()), - databaseURL: process.env.REACT_APP_DATABASE_URL, -}); +if (process.env.NODE_ENV === 'test' || process.env.FIRESTORE_EMULATOR_HOST) { + admin.initializeApp({ projectId: 'cuapts-68201' }); +} else { + admin.initializeApp({ + credential: admin.credential.cert(hydrateServiceAccount()), + databaseURL: process.env.REACT_APP_DATABASE_URL, + }); +} const db = admin.firestore(); const auth = admin.auth(); diff --git a/backend/src/server.test.ts b/backend/src/server.test.ts index f0d19a97..555565d8 100644 --- a/backend/src/server.test.ts +++ b/backend/src/server.test.ts @@ -38,7 +38,7 @@ describe('firestore permissions', () => { describe('Faqs', () => { // the get request should get faqs it('get faqs', async () => { - const response = await request(app).get('/'); + const response = await request(app).get('/api/faqs'); expect(response.status).toEqual(200); }); @@ -51,3 +51,97 @@ describe('Faqs', () => { return Promise.resolve(); }); }); + +describe('Tags', () => { + beforeEach(async () => { + await clearFirestoreData({ + projectId: 'cuapts-68201', + }); + + await db.collection('buildings').doc('apt1').set({ + name: 'Apt 1', + address: '123 Test St', + landlordId: null, + numBaths: 1, + numBeds: 1, + photos: [], + area: 'COLLEGETOWN', + latitude: 42.0, + longitude: -76.0, + price: 1000, + distanceToCampus: 10, + }); + }); + + it('POST /api/tags creates a new tag (returns {id,name})', async () => { + const response = await request(app).post('/api/tags').send({ name: 'Pet Friendly' }); + expect(response.status).toEqual(200); + const body = JSON.parse(response.text); + expect(typeof body.id).toEqual('string'); + expect(body.name).toEqual('Pet Friendly'); + }); + + it('POST /api/tags with same name returns existing tag id (no duplicates)', async () => { + const r1 = await request(app).post('/api/tags').send({ name: 'Pet Friendly' }); + const t1 = JSON.parse(r1.text); + const r2 = await request(app).post('/api/tags').send({ name: ' pet friendly ' }); + const t2 = JSON.parse(r2.text); + expect(t2.id).toEqual(t1.id); + + const tagsSnap = await db.collection('tags').get(); + expect(tagsSnap.docs.length).toEqual(1); + }); + + it('POST /api/apts/:id/tags/:tagId attaches tag idempotently', async () => { + const tagResp = await request(app).post('/api/tags').send({ name: 'Laundry' }); + const tag = JSON.parse(tagResp.text); + + const r1 = await request(app).post(`/api/apts/apt1/tags/${tag.id}`).send(); + expect(r1.status).toEqual(200); + const tags1 = JSON.parse(r1.text); + expect(tags1).toEqual([tag.id]); + + const r2 = await request(app).post(`/api/apts/apt1/tags/${tag.id}`).send(); + expect(r2.status).toEqual(200); + const tags2 = JSON.parse(r2.text); + expect(tags2).toEqual([tag.id]); + + const apt = await db.collection('buildings').doc('apt1').get(); + expect(apt.data()?.tags).toEqual([tag.id]); + }); + + it('DELETE /api/apts/:id/tags/:tagId detaches tag idempotently', async () => { + const tagResp = await request(app).post('/api/tags').send({ name: 'Gym' }); + const tag = JSON.parse(tagResp.text); + + await request(app).post(`/api/apts/apt1/tags/${tag.id}`).send(); + + const d1 = await request(app).delete(`/api/apts/apt1/tags/${tag.id}`).send(); + expect(d1.status).toEqual(200); + expect(JSON.parse(d1.text)).toEqual([]); + + const d2 = await request(app).delete(`/api/apts/apt1/tags/${tag.id}`).send(); + expect(d2.status).toEqual(200); + expect(JSON.parse(d2.text)).toEqual([]); + }); + + it('GET /api/apts/:id/tags returns correct tag names', async () => { + const t1Resp = await request(app).post('/api/tags').send({ name: 'Pet Friendly' }); + const t2Resp = await request(app).post('/api/tags').send({ name: 'Laundry In-Unit' }); + const t1 = JSON.parse(t1Resp.text); + const t2 = JSON.parse(t2Resp.text); + + await request(app).post(`/api/apts/apt1/tags/${t1.id}`).send(); + await request(app).post(`/api/apts/apt1/tags/${t2.id}`).send(); + + const resp = await request(app).get('/api/apts/apt1/tags'); + expect(resp.status).toEqual(200); + const tags = JSON.parse(resp.text); + expect(tags).toEqual( + expect.arrayContaining([ + { id: t1.id, name: 'Pet Friendly' }, + { id: t2.id, name: 'Laundry In-Unit' }, + ]) + ); + }); +}); diff --git a/backend/test.sh b/backend/test.sh index fa4c8f50..30c5332f 100755 --- a/backend/test.sh +++ b/backend/test.sh @@ -1 +1 @@ -NODE_ENV=test jest --detectOpenHandles --forceExit \ No newline at end of file +NODE_ENV=test ./node_modules/.bin/jest --detectOpenHandles --forceExit \ No newline at end of file diff --git a/common/types/db-types.ts b/common/types/db-types.ts index 20463657..e90a4ac7 100644 --- a/common/types/db-types.ts +++ b/common/types/db-types.ts @@ -51,6 +51,12 @@ export type Landlord = { export type LandlordWithId = Landlord & Id; export type LandlordWithLabel = LandlordWithId & { readonly label: 'LANDLORD' }; +export type Tag = { + readonly name: string; +}; + +export type TagWithId = Tag & Id; + export type Apartment = { readonly name: string; readonly address: string; // may change to placeID for Google Maps integration @@ -58,6 +64,7 @@ export type Apartment = { readonly numBaths: number | null; readonly numBeds: number | null; readonly photos: readonly string[]; // can be empty + readonly tags?: readonly string[]; readonly area: 'COLLEGETOWN' | 'WEST' | 'NORTH' | 'DOWNTOWN' | 'OTHER'; readonly latitude: number; readonly longitude: number; diff --git a/yarn.lock b/yarn.lock index 1c814930..305dc17d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7862,7 +7862,7 @@ jest-circus@26.6.0: stack-utils "^2.0.2" throat "^5.0.0" -jest-cli@^26.6.0: +jest-cli@^26.6.0, jest-cli@^26.6.3: version "26.6.3" resolved "https://registry.npmjs.org/jest-cli/-/jest-cli-26.6.3.tgz" integrity sha512-GF9noBSa9t08pSyl3CY4frMrqp+aQXFGFkf5hEPbh/pIUFYWMK6ZLTfbmadxJVcJrdRoChlWQsA2VkJcDFK8hg== @@ -8285,6 +8285,15 @@ jest@26.6.0: import-local "^3.0.2" jest-cli "^26.6.0" +jest@^26.6.0: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest/-/jest-26.6.3.tgz#40e8fdbe48f00dfa1f0ce8121ca74b88ac9148ef" + integrity sha512-lGS5PXGAzR4RF7V5+XObhqz2KZIDUA1yD0DG6pBVmy10eh0ZIXQImRuzocsI/N2XZ1GrLFwTS27In2i2jlpq1Q== + dependencies: + "@jest/core" "^26.6.3" + import-local "^3.0.2" + jest-cli "^26.6.3" + jose@^2.0.5: version "2.0.5" resolved "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz"