Skip to content

Commit b13bd16

Browse files
samuelim01McNaBry
andauthored
Add and integrate match service (#56)
* Initialize Match Service * User: Remove extra fields * Match: Implement /request routes * Match: Move DB logic into repository.ts * Match: Init message broker * Match: Add endpoints and consumers * Small fixes * Fix minor typos * Modify requests to use POST body rather than query params * Ensure match request only finds requests within valid time * Match: Use gateway * Add Match Service * Integrate matching into frontend * Add handling for timeout and cancellation of match * Fix dependency issues * Ensure logging for each match request * Fix recursive call to closeDialog() Co-authored-by: Bryan Lee <[email protected]> * Implement match frontend timer * Fix one minute being defined as 20 seconds on match service Co-authored-by: Bryan Lee <[email protected]> * Integrate navbar * Minor code clean up * Reroute login to /matching * Clean up match frontend code * Reorder function declarations for finding match dialog * Remove unecessary comments * Add loading state for start matching button when initiating a match * Adjust height of matching component * Extend healthcheck time for broker * Refactor match service: PUT /request * Simplified the endpoint to solely reset the validity of the match request, removing any unnecessary code. * Move match DB methods into repository.ts * Add Match Service README * Fix minor typos * Fix DB calls not returning updated objects * Enable tests for match service * Clean up code * Fix bug with clearing topics input on match page * When clearing the input field, topics is temporarily set to null before the onClear event is emitted * This causes hasQuestionsValidator to reference a null variable * The fix is to add an additional Validators.required to prevent hasQuestionsValidator from firing before topics is set to an empty array --------- Co-authored-by: McNaBry <[email protected]>
1 parent b858491 commit b13bd16

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+6205
-36
lines changed

.env.sample

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ USER_DB_LOCAL_URI=mongodb://user-db:27017/user
1313
USER_DB_USERNAME=user
1414
USER_DB_PASSWORD=password
1515

16+
# Match Service
17+
MATCH_DB_CLOUD_URI=<FILL-THIS-IN>
18+
MATCH_DB_LOCAL_URI=mongodb://match-db:27017/match
19+
MATCH_DB_USERNAME=user
20+
MATCH_DB_PASSWORD=password
21+
1622
# Secret for creating JWT signature
1723
JWT_SECRET=you-can-replace-this-with-your-own-secret
1824

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
strategy:
2424
fail-fast: false
2525
matrix:
26-
service: [frontend, services/question, services/user]
26+
service: [frontend, services/question, services/user, services/match]
2727
steps:
2828
- uses: actions/checkout@v4
2929
- name: Use Node.js

compose.dev.yml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,20 @@ services:
2626

2727
user-db:
2828
ports:
29-
- 27018:27017
29+
- 27018:27017
30+
31+
match:
32+
command: npm run dev
33+
ports:
34+
- 8083:8083
35+
volumes:
36+
- /app/node_modules
37+
- ./services/match:/app
38+
39+
match-db:
40+
ports:
41+
- 27019:27017
42+
43+
match-broker:
44+
ports:
45+
- 5672:5672

compose.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ services:
1616
- 8080:8080
1717
volumes:
1818
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
19+
depends_on:
20+
- question
21+
- user
22+
- match
1923
networks:
2024
- gateway-network
2125

@@ -78,10 +82,55 @@ services:
7882
- user-db-network
7983
command: --quiet
8084
restart: always
85+
86+
match:
87+
container_name: match
88+
image: match
89+
build:
90+
context: services/match
91+
dockerfile: Dockerfile
92+
environment:
93+
DB_CLOUD_URI: ${MATCH_DB_CLOUD_URI}
94+
DB_LOCAL_URI: ${MATCH_DB_LOCAL_URI}
95+
DB_USERNAME: ${MATCH_DB_USERNAME}
96+
DB_PASSWORD: ${MATCH_DB_PASSWORD}
97+
depends_on:
98+
match-broker:
99+
condition: service_healthy
100+
networks:
101+
- gateway-network
102+
- match-db-network
103+
restart: always
104+
105+
match-db:
106+
container_name: match-db
107+
image: mongo:7.0.14
108+
environment:
109+
MONGO_INITDB_ROOT_USERNAME: ${MATCH_DB_USERNAME}
110+
MONGO_INITDB_ROOT_PASSWORD: ${MATCH_DB_PASSWORD}
111+
volumes:
112+
- match-db:/data/db
113+
networks:
114+
- match-db-network
115+
restart: always
116+
117+
match-broker:
118+
container_name: match-broker
119+
hostname: match-broker
120+
image: rabbitmq:4.0.2
121+
networks:
122+
- match-db-network
123+
healthcheck:
124+
test: rabbitmq-diagnostics check_port_connectivity
125+
interval: 30s
126+
timeout: 30s
127+
retries: 10
128+
start_period: 30s
81129

82130
volumes:
83131
question-db:
84132
user-db:
133+
match-db:
85134

86135
networks:
87136
gateway-network:
@@ -90,3 +139,5 @@ networks:
90139
driver: bridge
91140
user-db-network:
92141
driver: bridge
142+
match-db-network:
143+
driver: bridge
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { HttpClient, HttpHeaders } from '@angular/common/http';
2+
import { Injectable } from '@angular/core';
3+
import { ApiService } from './api.service';
4+
import { MatchRequest, MatchResponse } from '../app/matching/match.model';
5+
6+
@Injectable({
7+
providedIn: 'root',
8+
})
9+
export class MatchService extends ApiService {
10+
protected apiPath = 'match/request';
11+
12+
private httpOptions = {
13+
headers: new HttpHeaders({
14+
'Content-Type': 'application/json',
15+
}),
16+
};
17+
18+
constructor(private http: HttpClient) {
19+
super();
20+
}
21+
22+
/**
23+
* Creates a match request with the provided details. The match request will
24+
* be valid for one minute.
25+
*/
26+
createMatchRequest(matchRequest: MatchRequest) {
27+
return this.http.post<MatchResponse>(this.apiUrl, matchRequest, this.httpOptions);
28+
}
29+
30+
/**
31+
* Retrieves the match request and its current status
32+
*/
33+
retrieveMatchRequest(id: string) {
34+
return this.http.get<MatchResponse>(this.apiUrl + '/' + id);
35+
}
36+
37+
/**
38+
* Refreshes the match request, effectively resetting its validity to one minute.
39+
*/
40+
updateMatchRequest(id: string) {
41+
return this.http.put<MatchResponse>(this.apiUrl + '/' + id, {}, this.httpOptions);
42+
}
43+
44+
/**
45+
* Deletes the match request
46+
*/
47+
deleteMatchRequest(id: string) {
48+
return this.http.delete<MatchResponse>(this.apiUrl + '/' + id);
49+
}
50+
}

frontend/src/app/account/login.component.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export class LoginComponent {
2626
) {
2727
//redirect to home if already logged in
2828
if (this.authenticationService.userValue) {
29-
this.router.navigate(['/']);
29+
this.router.navigate(['/matching']);
3030
}
3131
}
3232

@@ -44,9 +44,7 @@ export class LoginComponent {
4444
// authenticationService returns an observable that we can subscribe to
4545
this.authenticationService.login(this.userForm.username, this.userForm.password).subscribe({
4646
next: () => {
47-
// get return url from route parameters or default to '/'
48-
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
49-
this.router.navigate([returnUrl]);
47+
this.router.navigate(['/matching']);
5048
},
5149
error: error => {
5250
this.isProcessingLogin = false;

frontend/src/app/account/register.component.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export class RegisterComponent {
4141
) {
4242
// redirect to home if already logged in
4343
if (this.authenticationService.userValue) {
44-
this.router.navigate(['/']);
44+
this.router.navigate(['/matching']);
4545
}
4646
}
4747

@@ -99,9 +99,7 @@ export class RegisterComponent {
9999
.pipe()
100100
.subscribe({
101101
next: () => {
102-
// get return url from route parameters or default to '/'
103-
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
104-
this.router.navigate([returnUrl]);
102+
this.router.navigate(['/matching']);
105103
},
106104
// error handling for registration because we assume there will be no errors with auto login
107105
error: error => {

frontend/src/app/matching/finding-match/finding-match.component.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
@if (isFindingMatch) {
1212
<p-progressSpinner styleClass="w-2rem h-2rem mt-0" strokeWidth="6" />
1313
<h2 class="mt-0 mb-0">Finding a Match...</h2>
14+
<div class="flex gap-2 align-items-center">
15+
<i class="pi pi-stopwatch"></i>
16+
<p class="m-0">Time Left: {{ matchTimeLeft }}</p>
17+
</div>
1418
} @else {
1519
<i class="pi pi-check text-4xl text-green-300"></i>
1620
<h2 class="mt-0 mb-0">Match Found!</h2>

frontend/src/app/matching/finding-match/finding-match.component.ts

Lines changed: 88 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { ButtonModule } from 'primeng/button';
55
import { ProgressSpinnerModule } from 'primeng/progressspinner';
66
import { ChipModule } from 'primeng/chip';
77
import { CommonModule } from '@angular/common';
8+
import { catchError, Observable, of, Subscription, switchMap, takeUntil, tap, timer } from 'rxjs';
9+
import { MessageService } from 'primeng/api';
10+
import { MatchService } from '../../../_services/match.service';
11+
import { MatchResponse, MatchStatus } from '../match.model';
812

913
@Component({
1014
selector: 'app-finding-match',
@@ -15,36 +19,109 @@ import { CommonModule } from '@angular/common';
1519
})
1620
export class FindingMatchComponent {
1721
@Input() userCriteria!: UserCriteria;
22+
@Input() matchId!: string;
1823
@Input() isVisible = false;
1924

2025
@Output() dialogClose = new EventEmitter<void>();
2126
@Output() matchFailed = new EventEmitter<void>();
2227
@Output() matchSuccess = new EventEmitter<void>();
2328

24-
isFindingMatch = true;
29+
protected isFindingMatch = true;
30+
protected matchTimeLeft = 0;
31+
protected matchTimeInterval!: NodeJS.Timeout;
32+
protected matchPoll!: Subscription;
33+
protected stopPolling$ = new EventEmitter();
2534

26-
closeDialog() {
27-
this.dialogClose.emit();
28-
}
35+
constructor(
36+
private matchService: MatchService,
37+
private messageService: MessageService,
38+
) {}
2939

3040
onMatchFailed() {
41+
this.stopTimer();
3142
this.matchFailed.emit();
3243
}
3344

3445
onMatchSuccess() {
46+
this.stopTimer();
3547
this.isFindingMatch = false;
3648
this.matchSuccess.emit();
3749
// Possible to handle routing to workspace here.
3850
}
3951

4052
onDialogShow() {
41-
// Simulate request to API and subsequent success/failure.
42-
setTimeout(() => {
43-
if (this.isVisible) {
44-
// Toggle to simulate different situations.
45-
// this.onMatchFailed();
46-
this.onMatchSuccess();
53+
this.startTimer(60);
54+
this.matchPoll = this.startPolling(5000).pipe(tap(), takeUntil(this.stopPolling$)).subscribe();
55+
}
56+
57+
startPolling(interval: number): Observable<MatchResponse | null> {
58+
return timer(0, interval).pipe(switchMap(() => this.requestData()));
59+
}
60+
61+
requestData() {
62+
return this.matchService.retrieveMatchRequest(this.matchId).pipe(
63+
tap((response: MatchResponse) => {
64+
console.log(response);
65+
const status: MatchStatus = response.data.status || MatchStatus.PENDING;
66+
switch (status) {
67+
case MatchStatus.MATCH_FOUND:
68+
this.onMatchSuccess();
69+
break;
70+
case MatchStatus.TIME_OUT:
71+
this.stopPolling$.next(false);
72+
this.onMatchFailed();
73+
break;
74+
// TODO: Add case for MatchStatus.COLLAB_CREATED
75+
}
76+
}),
77+
catchError(() => {
78+
this.messageService.add({
79+
severity: 'error',
80+
summary: 'Error',
81+
detail: `Something went wrong while matching.`,
82+
life: 3000,
83+
});
84+
this.closeDialog();
85+
return of(null);
86+
}),
87+
);
88+
}
89+
90+
closeDialog() {
91+
this.stopTimer();
92+
this.matchPoll.unsubscribe();
93+
this.matchService.deleteMatchRequest(this.matchId).subscribe({
94+
next: response => {
95+
console.log(response);
96+
},
97+
error: () => {
98+
this.messageService.add({
99+
severity: 'error',
100+
summary: 'Error',
101+
detail: `Something went wrong while cancelling your match.`,
102+
life: 3000,
103+
});
104+
},
105+
complete: () => {
106+
this.dialogClose.emit();
107+
},
108+
});
109+
}
110+
111+
startTimer(time: number) {
112+
this.matchTimeLeft = time;
113+
this.matchTimeInterval = setInterval(() => {
114+
if (this.matchTimeLeft > 0) {
115+
this.matchTimeLeft--;
116+
} else {
117+
this.stopTimer();
47118
}
48-
}, 3000);
119+
}, 1000);
120+
}
121+
122+
stopTimer() {
123+
if (this.matchTimeInterval) {
124+
clearInterval(this.matchTimeInterval);
125+
}
49126
}
50127
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Difficulty } from './user-criteria.model';
2+
3+
export interface MatchRequest {
4+
topics: string[];
5+
difficulty: Difficulty;
6+
}
7+
8+
export enum MatchStatus {
9+
PENDING = 'PENDING',
10+
TIME_OUT = 'TIME_OUT',
11+
MATCH_FOUND = 'MATCH_FOUND',
12+
COLLAB_CREATED = 'COLLAB_CREATED',
13+
}
14+
15+
export interface MatchRequestStatus {
16+
_id: string;
17+
userId: string;
18+
username: string;
19+
createdAt: Date;
20+
updatedAt: Date;
21+
topics: string[];
22+
difficulty: Difficulty;
23+
status?: MatchStatus;
24+
pairId?: string;
25+
collabId?: string;
26+
}
27+
28+
export interface BaseResponse {
29+
status: string;
30+
message: string;
31+
}
32+
33+
export interface MatchResponse extends BaseResponse {
34+
data: MatchRequestStatus;
35+
}

0 commit comments

Comments
 (0)