Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
119 changes: 119 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
LandlordWithLabel,
ApartmentWithLabel,
ApartmentWithId,
TagWithId,
CantFindApartmentForm,
CantFindApartmentFormWithId,
QuestionForm,
Expand All @@ -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');
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 8 additions & 4 deletions backend/src/firebase-config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
96 changes: 95 additions & 1 deletion backend/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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' },
])
);
});
});
2 changes: 1 addition & 1 deletion backend/test.sh
Original file line number Diff line number Diff line change
@@ -1 +1 @@
NODE_ENV=test jest --detectOpenHandles --forceExit
NODE_ENV=test ./node_modules/.bin/jest --detectOpenHandles --forceExit
7 changes: 7 additions & 0 deletions common/types/db-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,20 @@ 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
readonly landlordId: string | null;
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;
Expand Down
11 changes: 10 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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==
Expand Down Expand Up @@ -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"
Expand Down
Loading