Skip to content

Commit 014fbe4

Browse files
yoganandanessgithub-actions[bot]
authored andcommitted
feat: coproposer invite accept without code
1 parent 5c009dd commit 014fbe4

25 files changed

Lines changed: 475 additions & 331 deletions

apps/backend/src/auth/ProposalAuthorization.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,10 +242,6 @@ export class ProposalAuthorization {
242242
return isInternalReviewerOnSomeTechnicalReview;
243243
}
244244

245-
// async isInvitee(agent: UserWithRole, proposalPk: number) {
246-
// return this.inviteDataSource.isInvitee(agent.id, proposalPk);
247-
// }
248-
249245
async hasReadRights(
250246
agent: UserWithRole | null,
251247
proposalOrProposalId: Proposal | number

apps/backend/src/datasources/InviteDataSource.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export interface GetInvitesFilter {
88
email?: string;
99
}
1010

11+
export interface GetCoProposerInvitesFilter extends GetInvitesFilter {
12+
proposalPk?: number;
13+
}
14+
1115
export interface InviteDataSource {
1216
create(args: {
1317
createdByUserId: number;
@@ -28,7 +32,7 @@ export interface InviteDataSource {
2832
isClaimed?: boolean
2933
): Promise<Invite[]>;
3034
getInvites(filter: GetInvitesFilter): Promise<Invite[]>;
31-
getProposalInvites(filter: GetInvitesFilter): Promise<Invite[]>;
35+
getCoProposerInvites(filter: GetCoProposerInvitesFilter): Promise<Invite[]>;
3236

3337
update(args: {
3438
id: number;

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

Lines changed: 60 additions & 3 deletions
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,7 +185,56 @@ export class InviteDataSourceMock implements InviteDataSource {
177185

178186
return invite;
179187
}
180-
getProposalInvites(filter: GetInvitesFilter): Promise<Invite[]> {
181-
throw new Error('Method not implemented.');
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+
});
182239
}
183240
}

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export let dummyProposal: Proposal;
2424
export let dummyProposalView: ProposalView;
2525
export let dummyProposalSubmitted: Proposal;
2626
export let dummyProposalWithNotActiveCall: Proposal;
27+
export let dummyProposalWithoutInvitation: Proposal;
2728

2829
let allProposals: Proposal[];
2930

@@ -165,6 +166,12 @@ export class ProposalDataSourceMock implements ProposalDataSource {
165166
callId: 2,
166167
});
167168

169+
dummyProposalWithoutInvitation = dummyProposalFactory({
170+
primaryKey: 4,
171+
title: 'Proposal without invitation',
172+
proposalId: 'no-invite',
173+
});
174+
168175
dummyProposalView = new ProposalView(
169176
1,
170177
'',
@@ -212,6 +219,7 @@ export class ProposalDataSourceMock implements ProposalDataSource {
212219
dummyProposal,
213220
dummyProposalSubmitted,
214221
dummyProposalWithNotActiveCall,
222+
dummyProposalWithoutInvitation,
215223
];
216224

217225
this.proposalsUpdated = [];
@@ -409,7 +417,7 @@ export class ProposalDataSourceMock implements ProposalDataSource {
409417
}
410418

411419
async getProposalById(proposalId: string): Promise<Proposal | null> {
412-
return dummyProposal.proposalId === proposalId ? dummyProposal : null;
420+
return allProposals.find((p) => p.proposalId === proposalId) || null;
413421
}
414422

415423
async doesProposalNeedTechReview(proposalPk: number): Promise<boolean> {

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
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';
711
export default class PostgresInviteDataSource implements InviteDataSource {
@@ -101,13 +105,13 @@ export default class PostgresInviteDataSource implements InviteDataSource {
101105
}
102106

103107
if (filter.email) {
104-
query.where('email', filter.email);
108+
query.whereRaw('lower(email) = ?', filter.email.toLowerCase());
105109
}
106110
})
107111
.then((invites: InviteRecord[]) => invites.map(createInviteObject));
108112
}
109113

110-
getProposalInvites(filter: GetInvitesFilter): Promise<Invite[]> {
114+
getCoProposerInvites(filter: GetCoProposerInvitesFilter): Promise<Invite[]> {
111115
return database
112116
.select('*')
113117
.from('invites')
@@ -138,7 +142,11 @@ export default class PostgresInviteDataSource implements InviteDataSource {
138142
}
139143

140144
if (filter.email) {
141-
query.where('email', 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);
142150
}
143151
})
144152
.then((invites: InviteRecord[]) => invites.map(createInviteObject));

apps/backend/src/mutations/InviteMutations.spec.ts

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,27 +47,27 @@ describe('Test Invite Mutations', () => {
4747

4848
test('A user can accept valid invite code', () => {
4949
return expect(
50-
inviteMutations.accept(dummyUserWithRole, 'invite-code')
50+
inviteMutations.acceptWithCode(dummyUserWithRole, 'invite-code')
5151
).resolves.toBeInstanceOf(Invite);
5252
});
5353

5454
test('A user can not accept invalid code', () => {
5555
return expect(
56-
inviteMutations.accept(dummyUserWithRole, 'invalid-invite-code')
56+
inviteMutations.acceptWithCode(dummyUserWithRole, 'invalid-invite-code')
5757
).resolves.toHaveProperty('reason', 'Invite code not found');
5858
});
5959

6060
test('A user can not accept code twice', async () => {
61-
await inviteMutations.accept(dummyUserWithRole, 'invite-code');
61+
await inviteMutations.acceptWithCode(dummyUserWithRole, 'invite-code');
6262

6363
return expect(
64-
inviteMutations.accept(dummyUserWithRole, 'invite-code')
64+
inviteMutations.acceptWithCode(dummyUserWithRole, 'invite-code')
6565
).resolves.toHaveProperty('reason', 'Invite code already claimed');
6666
});
6767

6868
test('A user can not accept expired code', async () => {
6969
return expect(
70-
inviteMutations.accept(dummyUserWithRole, 'expired-invite-code')
70+
inviteMutations.acceptWithCode(dummyUserWithRole, 'expired-invite-code')
7171
).resolves.toHaveProperty('reason', 'Invite code has expired');
7272
});
7373

@@ -375,4 +375,43 @@ describe('Test Invite Mutations', () => {
375375
'user-office-registration-invitation-co-proposer'
376376
);
377377
});
378+
379+
test('A user can accept valid co proposer invite without code', async () => {
380+
const invite = await inviteMutations.acceptCoProposerInvite(
381+
{ ...dummyUserWithRole, email: 'test2@example.com' },
382+
'shortCode'
383+
);
384+
385+
expect(invite).toBeInstanceOf(Invite);
386+
});
387+
388+
test('A user can not accept co proposer invite without code if email does not match', async () => {
389+
const invite = await inviteMutations.acceptCoProposerInvite(
390+
{ ...dummyUserWithRole, email: 'mismatch@example.com' },
391+
'shortCode'
392+
);
393+
394+
expect(invite).toBeInstanceOf(Rejection);
395+
expect((invite as Rejection).reason).toBe('Invite not found');
396+
});
397+
398+
test('A user can not accept co proposer invite without code if proposal is invalid', async () => {
399+
const invite = await inviteMutations.acceptCoProposerInvite(
400+
dummyUserWithRole,
401+
'invalid-short-code'
402+
);
403+
404+
expect(invite).toBeInstanceOf(Rejection);
405+
expect((invite as Rejection).reason).toBe('Proposal not found');
406+
});
407+
408+
test('A user cannot accept a non existing co proposer invite for an existing proposal without code', async () => {
409+
const invite = await inviteMutations.acceptCoProposerInvite(
410+
dummyUserWithRole,
411+
'no-invite'
412+
);
413+
414+
expect(invite).toBeInstanceOf(Rejection);
415+
expect((invite as Rejection).reason).toBe('Invite not found');
416+
});
378417
});

apps/backend/src/mutations/InviteMutations.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { ApplicationEvent } from '../events/applicationEvents';
2020
import { Event } from '../events/event.enum';
2121
import { Invite } from '../models/Invite';
2222
import { rejection, Rejection } from '../models/Rejection';
23-
import { Role } from '../models/Role';
23+
import { Role, Roles } from '../models/Role';
2424
import { SettingsId } from '../models/Settings';
2525
import { UserRole, UserWithRole } from '../models/User';
2626
import { SetCoProposerInvitesInput } from '../resolvers/mutations/SetCoProposerInvitesMutation';
@@ -53,7 +53,7 @@ export default class InviteMutations {
5353
) {}
5454

5555
@Authorized()
56-
async accept(
56+
async acceptWithCode(
5757
agent: UserWithRole | null,
5858
code: string
5959
): Promise<Invite | Rejection> {
@@ -82,6 +82,37 @@ export default class InviteMutations {
8282
return updatedInvite;
8383
}
8484

85+
@Authorized([Roles.USER])
86+
async acceptCoProposerInvite(agent: UserWithRole | null, proposalId: string) {
87+
const proposal = await this.proposalDataSource.getProposalById(proposalId);
88+
if (!proposal) {
89+
return rejection('Proposal not found', { proposalId });
90+
}
91+
if (!agent) {
92+
return rejection('User not found', { proposalId });
93+
}
94+
const [invite] = await this.inviteDataSource.getCoProposerInvites({
95+
proposalPk: proposal.primaryKey,
96+
email: agent.email,
97+
isClaimed: false,
98+
isExpired: false,
99+
});
100+
if (!invite) {
101+
return rejection('Invite not found', { proposalId });
102+
}
103+
104+
const updatedInvite = await this.inviteDataSource.update({
105+
id: invite.id,
106+
claimedAt: new Date(),
107+
claimedByUserId: agent.id,
108+
});
109+
110+
await this.processAcceptedRoleClaims(agent.id, updatedInvite);
111+
await this.processAcceptedCoProposerClaims(agent.id, updatedInvite);
112+
113+
return updatedInvite;
114+
}
115+
85116
private async getCoProposerInvites(proposalPk: number): Promise<Invite[]> {
86117
const existingClaims =
87118
await this.coProposerClaimDataSource.findByProposalPk(proposalPk);

apps/backend/src/queries/InviteQueries.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,12 @@ export default class InviteQueries {
5959
}
6060

6161
@Authorized()
62-
async getProposalInvites(agent: UserWithRole | null) {
62+
async getPendingCoProposerInvites(agent: UserWithRole | null) {
6363
if (!agent) {
6464
return [];
6565
}
6666

67-
const invites = await this.dataSource.getProposalInvites({
67+
const invites = await this.dataSource.getCoProposerInvites({
6868
email: agent.email,
6969
isClaimed: false,
7070
isExpired: false,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Arg, Ctx, Mutation } from 'type-graphql';
2+
3+
import { ResolverContext } from '../../context';
4+
import { Invite } from '../types/Invite';
5+
6+
export class AcceptCoProposerInviteMutation {
7+
@Mutation(() => Invite)
8+
acceptCoProposerInvite(
9+
@Arg('proposalId') proposalId: string,
10+
@Ctx() context: ResolverContext
11+
) {
12+
return context.mutations.invite.acceptCoProposerInvite(
13+
context.user,
14+
proposalId
15+
);
16+
}
17+
}

apps/backend/src/resolvers/mutations/AcceptInviteMutation.ts

Lines changed: 0 additions & 11 deletions
This file was deleted.

0 commit comments

Comments
 (0)