Skip to content

Commit d8bd206

Browse files
feat: user office automatic joining of invitees to the (#1260)
1 parent 22e8909 commit d8bd206

33 files changed

Lines changed: 766 additions & 50 deletions
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
DO
2+
$DO$
3+
DECLARE
4+
invite_id_var1 int;
5+
invite_id_var2 int;
6+
invite_id_var3 int;
7+
invite_id_var4 int;
8+
BEGIN
9+
10+
INSERT INTO invites (code, email, created_by, created_at, claimed_by, claimed_at, is_email_sent, expires_at, template_id)
11+
VALUES ('DAVE001', 'david@teleworm.us', 1, NOW(), NULL, NULL, false, NULL, 'user-office-registration-invitation-co-proposer');
12+
13+
SELECT invite_id
14+
INTO invite_id_var1
15+
FROM invites
16+
WHERE email = 'david@teleworm.us' AND code = 'DAVE001';
17+
18+
INSERT INTO invites (code, email, created_by, created_at, claimed_by, claimed_at, is_email_sent, expires_at, template_id)
19+
VALUES ('BEN001', 'ben@inbox.com', 1, NOW(), NULL, NULL, false, NULL, 'user-office-registration-invitation-co-proposer');
20+
21+
SELECT invite_id
22+
INTO invite_id_var2
23+
FROM invites
24+
WHERE email = 'ben@inbox.com' AND code = 'BEN001';
25+
26+
INSERT INTO co_proposer_claims (invite_id, proposal_pk) VALUES (invite_id_var1, 1);
27+
INSERT INTO co_proposer_claims (invite_id, proposal_pk) VALUES (invite_id_var2, 1);
28+
29+
INSERT INTO invites (code, email, created_by, created_at, claimed_by, claimed_at, is_email_sent, expires_at, template_id)
30+
VALUES ('DAVE002', 'david@teleworm.us', 1, NOW(), NULL, NULL, false, NULL, '');
31+
32+
SELECT invite_id
33+
INTO invite_id_var3
34+
FROM invites
35+
WHERE email = 'david@teleworm.us' AND code = 'DAVE002';
36+
37+
INSERT INTO invites (code, email, created_by, created_at, claimed_by, claimed_at, is_email_sent, expires_at, template_id)
38+
VALUES ('BEN002', 'ben@inbox.com', 1, NOW(), NULL, NULL, false, NULL, '');
39+
40+
SELECT invite_id
41+
INTO invite_id_var4
42+
FROM invites
43+
WHERE email = 'ben@inbox.com' AND code = 'BEN002';
44+
45+
INSERT INTO co_proposer_claims (invite_id, proposal_pk) VALUES (invite_id_var3, 2);
46+
INSERT INTO co_proposer_claims (invite_id, proposal_pk) VALUES (invite_id_var4, 2);
47+
48+
END;
49+
$DO$
50+
LANGUAGE plpgsql;

apps/backend/src/datasources/InviteDataSource.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ export interface GetInvitesFilter {
55
createdAfter?: Date;
66
isClaimed?: boolean;
77
isExpired?: boolean;
8+
email?: string;
9+
}
10+
11+
export interface GetCoProposerInvitesFilter extends GetInvitesFilter {
12+
proposalPk?: number;
813
}
914

1015
export interface InviteDataSource {
@@ -27,6 +32,7 @@ export interface InviteDataSource {
2732
isClaimed?: boolean
2833
): Promise<Invite[]>;
2934
getInvites(filter: GetInvitesFilter): Promise<Invite[]>;
35+
getCoProposerInvites(filter: GetCoProposerInvitesFilter): Promise<Invite[]>;
3036

3137
update(args: {
3238
id: number;

apps/backend/src/datasources/ProposalDataSource.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Event } from '../events/event.enum';
22
import { Call } from '../models/Call';
3-
import { Proposal, Proposals } from '../models/Proposal';
3+
import { InvitedProposal, Proposal, Proposals } from '../models/Proposal';
44
import { ProposalView } from '../models/ProposalView';
55
import { TechnicalReview } from '../models/TechnicalReview';
66
import { UserWithRole } from '../models/User';
@@ -92,4 +92,5 @@ export interface ProposalDataSource {
9292
sortDirection?: string,
9393
searchText?: string
9494
): Promise<{ totalCount: number; proposals: ProposalView[] }>;
95+
getInvitedProposal(inviteId: number): Promise<InvitedProposal | null>;
9596
}

apps/backend/src/datasources/mockups/InviteDataSource.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@ import { inject, injectable } from 'tsyringe';
22

33
import { Tokens } from '../../config/Tokens';
44
import { EmailTemplateId } from '../../eventHandlers/email/essEmailHandler';
5+
import { CoProposerClaim } from '../../models/CoProposerClaim';
56
import { Invite } from '../../models/Invite';
67
import { CoProposerClaimDataSource } from '../CoProposerClaimDataSource';
7-
import { GetInvitesFilter, InviteDataSource } from '../InviteDataSource';
8+
import {
9+
GetCoProposerInvitesFilter,
10+
GetInvitesFilter,
11+
InviteDataSource,
12+
} from '../InviteDataSource';
813

914
@injectable()
1015
export class InviteDataSourceMock implements InviteDataSource {
1116
private invites: Invite[];
17+
private coProposerClaims: CoProposerClaim[];
1218

1319
constructor(
1420
@inject(Tokens.CoProposerClaimDataSource)
@@ -86,6 +92,8 @@ export class InviteDataSourceMock implements InviteDataSource {
8692
EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER
8793
),
8894
];
95+
96+
this.coProposerClaims = [new CoProposerClaim(2, 1)];
8997
}
9098

9199
async findByCode(code: string): Promise<Invite | null> {
@@ -177,4 +185,56 @@ export class InviteDataSourceMock implements InviteDataSource {
177185

178186
return invite;
179187
}
188+
getCoProposerInvites(filter: GetCoProposerInvitesFilter): Promise<Invite[]> {
189+
return new Promise((resolve) => {
190+
const filteredInvites = this.invites.filter((invite) => {
191+
if (filter.createdBefore) {
192+
if (invite.createdAt >= filter.createdBefore) {
193+
return false;
194+
}
195+
}
196+
197+
if (filter.createdAfter) {
198+
if (invite.createdAt <= filter.createdAfter) {
199+
return false;
200+
}
201+
}
202+
203+
if (filter.isClaimed !== undefined) {
204+
if (invite.claimedAt === null && filter.isClaimed) {
205+
return false;
206+
}
207+
if (invite.claimedAt !== null && !filter.isClaimed) {
208+
return false;
209+
}
210+
}
211+
212+
if (filter.isExpired) {
213+
if (invite.expiresAt && invite.expiresAt < new Date()) {
214+
return false;
215+
}
216+
}
217+
218+
if (filter.email) {
219+
if (invite.email !== filter.email) {
220+
return false;
221+
}
222+
}
223+
224+
if (filter.proposalPk) {
225+
const inviteIds = this.coProposerClaims
226+
.filter((claim) => claim.proposalPk === filter.proposalPk)
227+
.map((claim) => claim.inviteId);
228+
229+
if (!inviteIds.includes(invite.id)) {
230+
return false;
231+
}
232+
}
233+
234+
return true;
235+
});
236+
237+
resolve(filteredInvites);
238+
});
239+
}
180240
}

apps/backend/src/datasources/mockups/ProposalDataSource.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import 'reflect-metadata';
22
import { Event } from '../../events/event.enum';
33
import { AllocationTimeUnits, Call } from '../../models/Call';
44
import { FapMeetingDecision } from '../../models/FapMeetingDecision';
5-
import { Proposal, ProposalEndStatus, Proposals } from '../../models/Proposal';
5+
import {
6+
InvitedProposal,
7+
Proposal,
8+
ProposalEndStatus,
9+
Proposals,
10+
} from '../../models/Proposal';
611
import { ProposalView } from '../../models/ProposalView';
712
import {
813
TechnicalReview,
@@ -19,6 +24,7 @@ export let dummyProposal: Proposal;
1924
export let dummyProposalView: ProposalView;
2025
export let dummyProposalSubmitted: Proposal;
2126
export let dummyProposalWithNotActiveCall: Proposal;
27+
export let dummyProposalWithoutInvitation: Proposal;
2228

2329
let allProposals: Proposal[];
2430

@@ -160,6 +166,12 @@ export class ProposalDataSourceMock implements ProposalDataSource {
160166
callId: 2,
161167
});
162168

169+
dummyProposalWithoutInvitation = dummyProposalFactory({
170+
primaryKey: 4,
171+
title: 'Proposal without invitation',
172+
proposalId: 'no-invite',
173+
});
174+
163175
dummyProposalView = new ProposalView(
164176
1,
165177
'',
@@ -207,6 +219,7 @@ export class ProposalDataSourceMock implements ProposalDataSource {
207219
dummyProposal,
208220
dummyProposalSubmitted,
209221
dummyProposalWithNotActiveCall,
222+
dummyProposalWithoutInvitation,
210223
];
211224

212225
this.proposalsUpdated = [];
@@ -404,7 +417,7 @@ export class ProposalDataSourceMock implements ProposalDataSource {
404417
}
405418

406419
async getProposalById(proposalId: string): Promise<Proposal | null> {
407-
return dummyProposal.proposalId === proposalId ? dummyProposal : null;
420+
return allProposals.find((p) => p.proposalId === proposalId) || null;
408421
}
409422

410423
async doesProposalNeedTechReview(proposalPk: number): Promise<boolean> {
@@ -422,4 +435,8 @@ export class ProposalDataSourceMock implements ProposalDataSource {
422435
) {
423436
return { totalCount: 1, proposals: [dummyProposalView] };
424437
}
438+
439+
getInvitedProposal(inviteId: number): Promise<InvitedProposal | null> {
440+
throw new Error('Method not implemented.');
441+
}
425442
}

apps/backend/src/datasources/postgres/InviteDataSource.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
22

33
import { Invite } from '../../models/Invite';
4-
import { GetInvitesFilter, InviteDataSource } from '../InviteDataSource';
4+
import {
5+
GetCoProposerInvitesFilter,
6+
GetInvitesFilter,
7+
InviteDataSource,
8+
} from '../InviteDataSource';
59
import database from './database';
610
import { createInviteObject, InviteRecord } from './records';
7-
811
export default class PostgresInviteDataSource implements InviteDataSource {
912
findCoProposerInvites(
1013
proposalPk: number,
@@ -100,6 +103,51 @@ export default class PostgresInviteDataSource implements InviteDataSource {
100103
if (filter.isExpired) {
101104
query.where('expires_at', '<', new Date());
102105
}
106+
107+
if (filter.email) {
108+
query.whereRaw('lower(email) = ?', filter.email.toLowerCase());
109+
}
110+
})
111+
.then((invites: InviteRecord[]) => invites.map(createInviteObject));
112+
}
113+
114+
getCoProposerInvites(filter: GetCoProposerInvitesFilter): Promise<Invite[]> {
115+
return database
116+
.select('*')
117+
.from('invites')
118+
.join(
119+
'co_proposer_claims',
120+
'invites.invite_id',
121+
'co_proposer_claims.invite_id'
122+
)
123+
.modify((query) => {
124+
if (filter.createdBefore) {
125+
query.where('created_at', '<', filter.createdBefore);
126+
}
127+
128+
if (filter.createdAfter) {
129+
query.where('created_at', '>', filter.createdAfter);
130+
}
131+
132+
if (filter.isClaimed !== undefined) {
133+
if (filter.isClaimed) {
134+
query.whereNotNull('claimed_at');
135+
} else {
136+
query.whereNull('claimed_at');
137+
}
138+
}
139+
140+
if (filter.isExpired) {
141+
query.where('expires_at', '<', new Date());
142+
}
143+
144+
if (filter.email) {
145+
query.whereRaw('lower(email) = ?', filter.email.toLowerCase());
146+
}
147+
148+
if (filter.proposalPk) {
149+
query.where('co_proposer_claims.proposal_pk', filter.proposalPk);
150+
}
103151
})
104152
.then((invites: InviteRecord[]) => invites.map(createInviteObject));
105153
}

apps/backend/src/datasources/postgres/ProposalDataSource.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { inject, injectable } from 'tsyringe';
77
import { Tokens } from '../../config/Tokens';
88
import { Event } from '../../events/event.enum';
99
import { Call } from '../../models/Call';
10-
import { Proposal, Proposals } from '../../models/Proposal';
10+
import { InvitedProposal, Proposal, Proposals } from '../../models/Proposal';
1111
import { ProposalView } from '../../models/ProposalView';
1212
import { getQuestionDefinition } from '../../models/questionTypes/QuestionRegistry';
1313
import { ReviewerFilter } from '../../models/Review';
@@ -40,6 +40,8 @@ import {
4040
TechnicalReviewRecord,
4141
TechniqueRecord,
4242
createProposalViewObjectWithTechniques,
43+
InvitedProposalRecord,
44+
createInvitedProposalObject,
4345
} from './records';
4446

4547
const fieldMap: { [key: string]: string } = {
@@ -1309,4 +1311,24 @@ export default class PostgresProposalDataSource implements ProposalDataSource {
13091311

13101312
return proposal ? createProposalObject(proposal[0]) : null;
13111313
}
1314+
1315+
async getInvitedProposal(inviteId: number): Promise<InvitedProposal | null> {
1316+
const proposals: InvitedProposalRecord[] | undefined = await database
1317+
.select(
1318+
'proposals.proposal_id',
1319+
'proposer.firstname as proposer_name',
1320+
'proposals.abstract',
1321+
'proposals.title'
1322+
)
1323+
.from('co_proposer_claims')
1324+
.join('proposals', {
1325+
'co_proposer_claims.proposal_pk': 'proposals.proposal_pk',
1326+
})
1327+
.join('users as proposer', {
1328+
'proposals.proposer_id': 'proposer.user_id',
1329+
})
1330+
.where('invite_id', inviteId);
1331+
1332+
return proposals ? createInvitedProposalObject(proposals[0]) : null;
1333+
}
13121334
}

apps/backend/src/datasources/postgres/records.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ import { Institution } from '../../models/Institution';
2424
import { Instrument } from '../../models/Instrument';
2525
import { Invite } from '../../models/Invite';
2626
import { PredefinedMessage } from '../../models/PredefinedMessage';
27-
import { Proposal, ProposalEndStatus } from '../../models/Proposal';
27+
import {
28+
InvitedProposal,
29+
Proposal,
30+
ProposalEndStatus,
31+
} from '../../models/Proposal';
2832
import { ProposalInternalComment } from '../../models/ProposalInternalComment';
2933
import { ProposalPdfTemplate } from '../../models/ProposalPdfTemplate';
3034
import { ProposalView } from '../../models/ProposalView';
@@ -173,6 +177,13 @@ export interface ProposalViewRecord {
173177
readonly techniques: ProposalViewTechnique[];
174178
}
175179

180+
export interface InvitedProposalRecord {
181+
readonly proposal_id: string;
182+
readonly proposer_name: string;
183+
readonly title: string;
184+
readonly abstract: string;
185+
}
186+
176187
export interface TopicRecord {
177188
readonly topic_id: number;
178189
readonly topic_title: string;
@@ -901,6 +912,15 @@ export const createProposalViewObject = (proposal: ProposalViewRecord) => {
901912
);
902913
};
903914

915+
export const createInvitedProposalObject = (record: InvitedProposalRecord) => {
916+
return new InvitedProposal(
917+
record.proposal_id,
918+
record.proposer_name,
919+
record.title,
920+
record.abstract
921+
);
922+
};
923+
904924
export const createFieldDependencyObject = (
905925
fieldDependency: FieldDependencyRecord & { natural_key: string }
906926
) => {

apps/backend/src/eventHandlers/email/essEmailHandler.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ describe('essEmailHandler co-proposer invites', () => {
6868

6969
const mockEvent = {
7070
type: Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED,
71+
loggedInUserId: 2,
7172
invite: {
7273
id: 1, // This will match the default mock data
7374
email: inviteEmail,

0 commit comments

Comments
 (0)