diff --git a/apps/backend/db_patches/db_seeds/0007_CoProposersInvite.sql b/apps/backend/db_patches/db_seeds/0007_CoProposersInvite.sql new file mode 100644 index 0000000000..2348e4af52 --- /dev/null +++ b/apps/backend/db_patches/db_seeds/0007_CoProposersInvite.sql @@ -0,0 +1,50 @@ +DO +$DO$ +DECLARE + invite_id_var1 int; + invite_id_var2 int; + invite_id_var3 int; + invite_id_var4 int; +BEGIN + + INSERT INTO invites (code, email, created_by, created_at, claimed_by, claimed_at, is_email_sent, expires_at, template_id) + VALUES ('DAVE001', 'david@teleworm.us', 1, NOW(), NULL, NULL, false, NULL, 'user-office-registration-invitation-co-proposer'); + + SELECT invite_id + INTO invite_id_var1 + FROM invites + WHERE email = 'david@teleworm.us' AND code = 'DAVE001'; + + INSERT INTO invites (code, email, created_by, created_at, claimed_by, claimed_at, is_email_sent, expires_at, template_id) + VALUES ('BEN001', 'ben@inbox.com', 1, NOW(), NULL, NULL, false, NULL, 'user-office-registration-invitation-co-proposer'); + + SELECT invite_id + INTO invite_id_var2 + FROM invites + WHERE email = 'ben@inbox.com' AND code = 'BEN001'; + + INSERT INTO co_proposer_claims (invite_id, proposal_pk) VALUES (invite_id_var1, 1); + INSERT INTO co_proposer_claims (invite_id, proposal_pk) VALUES (invite_id_var2, 1); + + INSERT INTO invites (code, email, created_by, created_at, claimed_by, claimed_at, is_email_sent, expires_at, template_id) + VALUES ('DAVE002', 'david@teleworm.us', 1, NOW(), NULL, NULL, false, NULL, ''); + + SELECT invite_id + INTO invite_id_var3 + FROM invites + WHERE email = 'david@teleworm.us' AND code = 'DAVE002'; + + INSERT INTO invites (code, email, created_by, created_at, claimed_by, claimed_at, is_email_sent, expires_at, template_id) + VALUES ('BEN002', 'ben@inbox.com', 1, NOW(), NULL, NULL, false, NULL, ''); + + SELECT invite_id + INTO invite_id_var4 + FROM invites + WHERE email = 'ben@inbox.com' AND code = 'BEN002'; + + INSERT INTO co_proposer_claims (invite_id, proposal_pk) VALUES (invite_id_var3, 2); + INSERT INTO co_proposer_claims (invite_id, proposal_pk) VALUES (invite_id_var4, 2); + +END; +$DO$ +LANGUAGE plpgsql; diff --git a/apps/backend/src/datasources/InviteDataSource.ts b/apps/backend/src/datasources/InviteDataSource.ts index ca73fc5670..3eed5a0fa7 100644 --- a/apps/backend/src/datasources/InviteDataSource.ts +++ b/apps/backend/src/datasources/InviteDataSource.ts @@ -5,6 +5,11 @@ export interface GetInvitesFilter { createdAfter?: Date; isClaimed?: boolean; isExpired?: boolean; + email?: string; +} + +export interface GetCoProposerInvitesFilter extends GetInvitesFilter { + proposalPk?: number; } export interface InviteDataSource { @@ -27,6 +32,7 @@ export interface InviteDataSource { isClaimed?: boolean ): Promise; getInvites(filter: GetInvitesFilter): Promise; + getCoProposerInvites(filter: GetCoProposerInvitesFilter): Promise; update(args: { id: number; diff --git a/apps/backend/src/datasources/ProposalDataSource.ts b/apps/backend/src/datasources/ProposalDataSource.ts index 6cd43080cd..9bffd6246d 100644 --- a/apps/backend/src/datasources/ProposalDataSource.ts +++ b/apps/backend/src/datasources/ProposalDataSource.ts @@ -1,6 +1,6 @@ import { Event } from '../events/event.enum'; import { Call } from '../models/Call'; -import { Proposal, Proposals } from '../models/Proposal'; +import { InvitedProposal, Proposal, Proposals } from '../models/Proposal'; import { ProposalView } from '../models/ProposalView'; import { TechnicalReview } from '../models/TechnicalReview'; import { UserWithRole } from '../models/User'; @@ -92,4 +92,5 @@ export interface ProposalDataSource { sortDirection?: string, searchText?: string ): Promise<{ totalCount: number; proposals: ProposalView[] }>; + getInvitedProposal(inviteId: number): Promise; } diff --git a/apps/backend/src/datasources/mockups/InviteDataSource.ts b/apps/backend/src/datasources/mockups/InviteDataSource.ts index 5447b1ed66..2d38115b47 100644 --- a/apps/backend/src/datasources/mockups/InviteDataSource.ts +++ b/apps/backend/src/datasources/mockups/InviteDataSource.ts @@ -2,13 +2,19 @@ import { inject, injectable } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { EmailTemplateId } from '../../eventHandlers/email/essEmailHandler'; +import { CoProposerClaim } from '../../models/CoProposerClaim'; import { Invite } from '../../models/Invite'; import { CoProposerClaimDataSource } from '../CoProposerClaimDataSource'; -import { GetInvitesFilter, InviteDataSource } from '../InviteDataSource'; +import { + GetCoProposerInvitesFilter, + GetInvitesFilter, + InviteDataSource, +} from '../InviteDataSource'; @injectable() export class InviteDataSourceMock implements InviteDataSource { private invites: Invite[]; + private coProposerClaims: CoProposerClaim[]; constructor( @inject(Tokens.CoProposerClaimDataSource) @@ -86,6 +92,8 @@ export class InviteDataSourceMock implements InviteDataSource { EmailTemplateId.USER_OFFICE_REGISTRATION_INVITATION_REVIEWER ), ]; + + this.coProposerClaims = [new CoProposerClaim(2, 1)]; } async findByCode(code: string): Promise { @@ -177,4 +185,56 @@ export class InviteDataSourceMock implements InviteDataSource { return invite; } + getCoProposerInvites(filter: GetCoProposerInvitesFilter): Promise { + return new Promise((resolve) => { + const filteredInvites = this.invites.filter((invite) => { + if (filter.createdBefore) { + if (invite.createdAt >= filter.createdBefore) { + return false; + } + } + + if (filter.createdAfter) { + if (invite.createdAt <= filter.createdAfter) { + return false; + } + } + + if (filter.isClaimed !== undefined) { + if (invite.claimedAt === null && filter.isClaimed) { + return false; + } + if (invite.claimedAt !== null && !filter.isClaimed) { + return false; + } + } + + if (filter.isExpired) { + if (invite.expiresAt && invite.expiresAt < new Date()) { + return false; + } + } + + if (filter.email) { + if (invite.email !== filter.email) { + return false; + } + } + + if (filter.proposalPk) { + const inviteIds = this.coProposerClaims + .filter((claim) => claim.proposalPk === filter.proposalPk) + .map((claim) => claim.inviteId); + + if (!inviteIds.includes(invite.id)) { + return false; + } + } + + return true; + }); + + resolve(filteredInvites); + }); + } } diff --git a/apps/backend/src/datasources/mockups/ProposalDataSource.ts b/apps/backend/src/datasources/mockups/ProposalDataSource.ts index 6e496eb67a..f9223826ea 100644 --- a/apps/backend/src/datasources/mockups/ProposalDataSource.ts +++ b/apps/backend/src/datasources/mockups/ProposalDataSource.ts @@ -2,7 +2,12 @@ import 'reflect-metadata'; import { Event } from '../../events/event.enum'; import { AllocationTimeUnits, Call } from '../../models/Call'; import { FapMeetingDecision } from '../../models/FapMeetingDecision'; -import { Proposal, ProposalEndStatus, Proposals } from '../../models/Proposal'; +import { + InvitedProposal, + Proposal, + ProposalEndStatus, + Proposals, +} from '../../models/Proposal'; import { ProposalView } from '../../models/ProposalView'; import { TechnicalReview, @@ -19,6 +24,7 @@ export let dummyProposal: Proposal; export let dummyProposalView: ProposalView; export let dummyProposalSubmitted: Proposal; export let dummyProposalWithNotActiveCall: Proposal; +export let dummyProposalWithoutInvitation: Proposal; let allProposals: Proposal[]; @@ -160,6 +166,12 @@ export class ProposalDataSourceMock implements ProposalDataSource { callId: 2, }); + dummyProposalWithoutInvitation = dummyProposalFactory({ + primaryKey: 4, + title: 'Proposal without invitation', + proposalId: 'no-invite', + }); + dummyProposalView = new ProposalView( 1, '', @@ -207,6 +219,7 @@ export class ProposalDataSourceMock implements ProposalDataSource { dummyProposal, dummyProposalSubmitted, dummyProposalWithNotActiveCall, + dummyProposalWithoutInvitation, ]; this.proposalsUpdated = []; @@ -404,7 +417,7 @@ export class ProposalDataSourceMock implements ProposalDataSource { } async getProposalById(proposalId: string): Promise { - return dummyProposal.proposalId === proposalId ? dummyProposal : null; + return allProposals.find((p) => p.proposalId === proposalId) || null; } async doesProposalNeedTechReview(proposalPk: number): Promise { @@ -422,4 +435,8 @@ export class ProposalDataSourceMock implements ProposalDataSource { ) { return { totalCount: 1, proposals: [dummyProposalView] }; } + + getInvitedProposal(inviteId: number): Promise { + throw new Error('Method not implemented.'); + } } diff --git a/apps/backend/src/datasources/postgres/InviteDataSource.ts b/apps/backend/src/datasources/postgres/InviteDataSource.ts index 58a819267d..30d52112a6 100644 --- a/apps/backend/src/datasources/postgres/InviteDataSource.ts +++ b/apps/backend/src/datasources/postgres/InviteDataSource.ts @@ -1,10 +1,13 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { Invite } from '../../models/Invite'; -import { GetInvitesFilter, InviteDataSource } from '../InviteDataSource'; +import { + GetCoProposerInvitesFilter, + GetInvitesFilter, + InviteDataSource, +} from '../InviteDataSource'; import database from './database'; import { createInviteObject, InviteRecord } from './records'; - export default class PostgresInviteDataSource implements InviteDataSource { findCoProposerInvites( proposalPk: number, @@ -100,6 +103,51 @@ export default class PostgresInviteDataSource implements InviteDataSource { if (filter.isExpired) { query.where('expires_at', '<', new Date()); } + + if (filter.email) { + query.whereRaw('lower(email) = ?', filter.email.toLowerCase()); + } + }) + .then((invites: InviteRecord[]) => invites.map(createInviteObject)); + } + + getCoProposerInvites(filter: GetCoProposerInvitesFilter): Promise { + return database + .select('*') + .from('invites') + .join( + 'co_proposer_claims', + 'invites.invite_id', + 'co_proposer_claims.invite_id' + ) + .modify((query) => { + if (filter.createdBefore) { + query.where('created_at', '<', filter.createdBefore); + } + + if (filter.createdAfter) { + query.where('created_at', '>', filter.createdAfter); + } + + if (filter.isClaimed !== undefined) { + if (filter.isClaimed) { + query.whereNotNull('claimed_at'); + } else { + query.whereNull('claimed_at'); + } + } + + if (filter.isExpired) { + query.where('expires_at', '<', new Date()); + } + + if (filter.email) { + query.whereRaw('lower(email) = ?', filter.email.toLowerCase()); + } + + if (filter.proposalPk) { + query.where('co_proposer_claims.proposal_pk', filter.proposalPk); + } }) .then((invites: InviteRecord[]) => invites.map(createInviteObject)); } diff --git a/apps/backend/src/datasources/postgres/ProposalDataSource.ts b/apps/backend/src/datasources/postgres/ProposalDataSource.ts index e1609b21f0..0e4090d8af 100644 --- a/apps/backend/src/datasources/postgres/ProposalDataSource.ts +++ b/apps/backend/src/datasources/postgres/ProposalDataSource.ts @@ -7,7 +7,7 @@ import { inject, injectable } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { Event } from '../../events/event.enum'; import { Call } from '../../models/Call'; -import { Proposal, Proposals } from '../../models/Proposal'; +import { InvitedProposal, Proposal, Proposals } from '../../models/Proposal'; import { ProposalView } from '../../models/ProposalView'; import { getQuestionDefinition } from '../../models/questionTypes/QuestionRegistry'; import { ReviewerFilter } from '../../models/Review'; @@ -40,6 +40,8 @@ import { TechnicalReviewRecord, TechniqueRecord, createProposalViewObjectWithTechniques, + InvitedProposalRecord, + createInvitedProposalObject, } from './records'; const fieldMap: { [key: string]: string } = { @@ -1309,4 +1311,24 @@ export default class PostgresProposalDataSource implements ProposalDataSource { return proposal ? createProposalObject(proposal[0]) : null; } + + async getInvitedProposal(inviteId: number): Promise { + const proposals: InvitedProposalRecord[] | undefined = await database + .select( + 'proposals.proposal_id', + 'proposer.firstname as proposer_name', + 'proposals.abstract', + 'proposals.title' + ) + .from('co_proposer_claims') + .join('proposals', { + 'co_proposer_claims.proposal_pk': 'proposals.proposal_pk', + }) + .join('users as proposer', { + 'proposals.proposer_id': 'proposer.user_id', + }) + .where('invite_id', inviteId); + + return proposals ? createInvitedProposalObject(proposals[0]) : null; + } } diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index d3abb964b8..11a8e6035e 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -24,7 +24,11 @@ import { Institution } from '../../models/Institution'; import { Instrument } from '../../models/Instrument'; import { Invite } from '../../models/Invite'; import { PredefinedMessage } from '../../models/PredefinedMessage'; -import { Proposal, ProposalEndStatus } from '../../models/Proposal'; +import { + InvitedProposal, + Proposal, + ProposalEndStatus, +} from '../../models/Proposal'; import { ProposalInternalComment } from '../../models/ProposalInternalComment'; import { ProposalPdfTemplate } from '../../models/ProposalPdfTemplate'; import { ProposalView } from '../../models/ProposalView'; @@ -173,6 +177,13 @@ export interface ProposalViewRecord { readonly techniques: ProposalViewTechnique[]; } +export interface InvitedProposalRecord { + readonly proposal_id: string; + readonly proposer_name: string; + readonly title: string; + readonly abstract: string; +} + export interface TopicRecord { readonly topic_id: number; readonly topic_title: string; @@ -901,6 +912,15 @@ export const createProposalViewObject = (proposal: ProposalViewRecord) => { ); }; +export const createInvitedProposalObject = (record: InvitedProposalRecord) => { + return new InvitedProposal( + record.proposal_id, + record.proposer_name, + record.title, + record.abstract + ); +}; + export const createFieldDependencyObject = ( fieldDependency: FieldDependencyRecord & { natural_key: string } ) => { diff --git a/apps/backend/src/eventHandlers/email/essEmailHandler.ts b/apps/backend/src/eventHandlers/email/essEmailHandler.ts index addff8f45e..e76b76ae7f 100644 --- a/apps/backend/src/eventHandlers/email/essEmailHandler.ts +++ b/apps/backend/src/eventHandlers/email/essEmailHandler.ts @@ -64,6 +64,9 @@ export async function essEmailHandler(event: ApplicationEvent) { switch (event.type) { case Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED: { const invite: Invite = event.invite; + const loggedInUserId = event.loggedInUserId; + + if (!loggedInUserId) return; const coProposerClaims = await coProposerDataSource.findByInviteId( invite.id @@ -101,9 +104,7 @@ export async function essEmailHandler(event: ApplicationEvent) { return; } - const claimer = await userDataSource.getUser( - invite.claimedByUserId as number - ); + const claimer = await userDataSource.getUser(loggedInUserId); if (!claimer) { logger.logError( 'No claimer found when trying to send invite accepted email', diff --git a/apps/backend/src/models/Proposal.ts b/apps/backend/src/models/Proposal.ts index c5457f618c..afdde1577d 100644 --- a/apps/backend/src/models/Proposal.ts +++ b/apps/backend/src/models/Proposal.ts @@ -52,6 +52,15 @@ export class Proposal { ) {} } +export class InvitedProposal { + constructor( + public proposalId: string, + public proposerName: string, + public title: string, + public abstract: string + ) {} +} + export class ProposalPks { constructor(public proposalPks: number[]) {} } diff --git a/apps/backend/src/mutations/InviteMutations.spec.ts b/apps/backend/src/mutations/InviteMutations.spec.ts index e1f8a1e907..d062362821 100644 --- a/apps/backend/src/mutations/InviteMutations.spec.ts +++ b/apps/backend/src/mutations/InviteMutations.spec.ts @@ -47,27 +47,27 @@ describe('Test Invite Mutations', () => { test('A user can accept valid invite code', () => { return expect( - inviteMutations.accept(dummyUserWithRole, 'invite-code') + inviteMutations.acceptWithCode(dummyUserWithRole, 'invite-code') ).resolves.toBeInstanceOf(Invite); }); test('A user can not accept invalid code', () => { return expect( - inviteMutations.accept(dummyUserWithRole, 'invalid-invite-code') + inviteMutations.acceptWithCode(dummyUserWithRole, 'invalid-invite-code') ).resolves.toHaveProperty('reason', 'Invite code not found'); }); test('A user can not accept code twice', async () => { - await inviteMutations.accept(dummyUserWithRole, 'invite-code'); + await inviteMutations.acceptWithCode(dummyUserWithRole, 'invite-code'); return expect( - inviteMutations.accept(dummyUserWithRole, 'invite-code') + inviteMutations.acceptWithCode(dummyUserWithRole, 'invite-code') ).resolves.toHaveProperty('reason', 'Invite code already claimed'); }); test('A user can not accept expired code', async () => { return expect( - inviteMutations.accept(dummyUserWithRole, 'expired-invite-code') + inviteMutations.acceptWithCode(dummyUserWithRole, 'expired-invite-code') ).resolves.toHaveProperty('reason', 'Invite code has expired'); }); @@ -375,4 +375,43 @@ describe('Test Invite Mutations', () => { 'user-office-registration-invitation-co-proposer' ); }); + + test('A user can accept valid co proposer invite without code', async () => { + const invite = await inviteMutations.acceptCoProposerInvite( + { ...dummyUserWithRole, email: 'test2@example.com' }, + 'shortCode' + ); + + expect(invite).toBeInstanceOf(Invite); + }); + + test('A user can not accept co proposer invite without code if email does not match', async () => { + const invite = await inviteMutations.acceptCoProposerInvite( + { ...dummyUserWithRole, email: 'mismatch@example.com' }, + 'shortCode' + ); + + expect(invite).toBeInstanceOf(Rejection); + expect((invite as Rejection).reason).toBe('Invite not found'); + }); + + test('A user can not accept co proposer invite without code if proposal is invalid', async () => { + const invite = await inviteMutations.acceptCoProposerInvite( + dummyUserWithRole, + 'invalid-short-code' + ); + + expect(invite).toBeInstanceOf(Rejection); + expect((invite as Rejection).reason).toBe('Proposal not found'); + }); + + test('A user cannot accept a non existing co proposer invite for an existing proposal without code', async () => { + const invite = await inviteMutations.acceptCoProposerInvite( + dummyUserWithRole, + 'no-invite' + ); + + expect(invite).toBeInstanceOf(Rejection); + expect((invite as Rejection).reason).toBe('Invite not found'); + }); }); diff --git a/apps/backend/src/mutations/InviteMutations.ts b/apps/backend/src/mutations/InviteMutations.ts index 8f0c63e061..679e22b3e8 100644 --- a/apps/backend/src/mutations/InviteMutations.ts +++ b/apps/backend/src/mutations/InviteMutations.ts @@ -20,7 +20,7 @@ import { ApplicationEvent } from '../events/applicationEvents'; import { Event } from '../events/event.enum'; import { Invite } from '../models/Invite'; import { rejection, Rejection } from '../models/Rejection'; -import { Role } from '../models/Role'; +import { Role, Roles } from '../models/Role'; import { SettingsId } from '../models/Settings'; import { UserRole, UserWithRole } from '../models/User'; import { SetCoProposerInvitesInput } from '../resolvers/mutations/SetCoProposerInvitesMutation'; @@ -53,10 +53,13 @@ export default class InviteMutations { ) {} @Authorized() - async accept( + async acceptWithCode( agent: UserWithRole | null, code: string ): Promise { + if (!agent) { + return rejection('User not found', { invite: code }); + } const invite = await this.inviteDataSource.findByCode(code); if (invite === null) { return rejection('Invite code not found', { invite: code }); @@ -69,15 +72,48 @@ export default class InviteMutations { return rejection('Invite code has expired', { invite: code }); } + await this.processAcceptedRoleClaims(agent.id, invite); + await this.processAcceptedCoProposerClaims(agent.id, invite); + await this.processAcceptedVisitRegistrationClaims(agent.id, invite); + const updatedInvite = await this.inviteDataSource.update({ id: invite.id, claimedAt: new Date(), claimedByUserId: agent!.id, }); - await this.processAcceptedRoleClaims(agent!.id, updatedInvite); - await this.processAcceptedCoProposerClaims(agent!.id, updatedInvite); - await this.processAcceptedVisitRegistrationClaims(agent!.id, updatedInvite); + return updatedInvite; + } + + @Authorized([Roles.USER]) + async acceptCoProposerInvite(agent: UserWithRole | null, proposalId: string) { + if (!agent) { + return rejection('User not found', { proposalId }); + } + + const proposal = await this.proposalDataSource.getProposalById(proposalId); + if (!proposal) { + return rejection('Proposal not found', { proposalId }); + } + + const [invite] = await this.inviteDataSource.getCoProposerInvites({ + proposalPk: proposal.primaryKey, + email: agent.email, + isClaimed: false, + isExpired: false, + }); + if (!invite) { + return rejection('Invite not found', { proposalId }); + } + + await this.processAcceptedRoleClaims(agent.id, invite); + await this.processAcceptedCoProposerClaims(agent.id, invite); + + const updatedInvite = await this.inviteDataSource.update({ + id: invite.id, + claimedAt: new Date(), + claimedByUserId: agent.id, + }); return updatedInvite; } diff --git a/apps/backend/src/queries/InviteQueries.ts b/apps/backend/src/queries/InviteQueries.ts index 531b1f0a05..5f975cf6f9 100644 --- a/apps/backend/src/queries/InviteQueries.ts +++ b/apps/backend/src/queries/InviteQueries.ts @@ -57,4 +57,19 @@ export default class InviteQueries { return invites; } + + @Authorized() + async getPendingCoProposerInvites(agent: UserWithRole | null) { + if (!agent) { + return []; + } + + const invites = await this.dataSource.getCoProposerInvites({ + email: agent.email, + isClaimed: false, + isExpired: false, + }); + + return invites; + } } diff --git a/apps/backend/src/queries/ProposalQueries.ts b/apps/backend/src/queries/ProposalQueries.ts index 909d2797b4..83a7a10014 100644 --- a/apps/backend/src/queries/ProposalQueries.ts +++ b/apps/backend/src/queries/ProposalQueries.ts @@ -198,4 +198,9 @@ export default class ProposalQueries { proposalPk ); } + + @Authorized([Roles.USER_OFFICER, Roles.USER]) + async getInvitedProposal(agent: UserWithRole | null, inviteId: number) { + return this.dataSource.getInvitedProposal(inviteId); + } } diff --git a/apps/backend/src/resolvers/mutations/AcceptCoProposerInviteMutation.ts b/apps/backend/src/resolvers/mutations/AcceptCoProposerInviteMutation.ts new file mode 100644 index 0000000000..2605e8a036 --- /dev/null +++ b/apps/backend/src/resolvers/mutations/AcceptCoProposerInviteMutation.ts @@ -0,0 +1,17 @@ +import { Arg, Ctx, Mutation } from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { Invite } from '../types/Invite'; + +export class AcceptCoProposerInviteMutation { + @Mutation(() => Invite) + acceptCoProposerInvite( + @Arg('proposalId') proposalId: string, + @Ctx() context: ResolverContext + ) { + return context.mutations.invite.acceptCoProposerInvite( + context.user, + proposalId + ); + } +} diff --git a/apps/backend/src/resolvers/mutations/AcceptInviteMutation.ts b/apps/backend/src/resolvers/mutations/AcceptInviteMutation.ts deleted file mode 100644 index 741ba38a6a..0000000000 --- a/apps/backend/src/resolvers/mutations/AcceptInviteMutation.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Arg, Ctx, Mutation } from 'type-graphql'; - -import { ResolverContext } from '../../context'; -import { Invite } from '../types/Invite'; - -export class AcceptInvite { - @Mutation(() => Invite) - acceptInvite(@Arg('code') code: string, @Ctx() context: ResolverContext) { - return context.mutations.invite.accept(context.user, code); - } -} diff --git a/apps/backend/src/resolvers/mutations/AcceptInviteWithCodeMutation.ts b/apps/backend/src/resolvers/mutations/AcceptInviteWithCodeMutation.ts new file mode 100644 index 0000000000..72b67cdd1d --- /dev/null +++ b/apps/backend/src/resolvers/mutations/AcceptInviteWithCodeMutation.ts @@ -0,0 +1,14 @@ +import { Arg, Ctx, Mutation } from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { Invite } from '../types/Invite'; + +export class AcceptInviteWithCodeMutation { + @Mutation(() => Invite) + acceptInviteWithCode( + @Arg('code') code: string, + @Ctx() context: ResolverContext + ) { + return context.mutations.invite.acceptWithCode(context.user, code); + } +} diff --git a/apps/backend/src/resolvers/types/Invite.ts b/apps/backend/src/resolvers/types/Invite.ts index bade9e18cd..91b5a3b55a 100644 --- a/apps/backend/src/resolvers/types/Invite.ts +++ b/apps/backend/src/resolvers/types/Invite.ts @@ -1,7 +1,18 @@ -import { Authorized, Field, Int, ObjectType } from 'type-graphql'; - +import { + Authorized, + Ctx, + Field, + FieldResolver, + Int, + ObjectType, + Resolver, + Root, +} from 'type-graphql'; + +import { ResolverContext } from '../../context'; import { Invite as InviteOrigin } from '../../models/Invite'; import { Roles } from '../../models/Role'; +import { InvitedProposal } from './Proposal'; @ObjectType() export class Invite implements Partial { @@ -33,3 +44,11 @@ export class Invite implements Partial { @Field(() => Date, { nullable: true }) public expiresAt: Date | null; } + +@Resolver(() => Invite) +export class InviteResolver { + @FieldResolver(() => InvitedProposal, { nullable: true }) + async proposal(@Root() invite: Invite, @Ctx() context: ResolverContext) { + return context.queries.proposal.dataSource.getInvitedProposal(invite.id); + } +} diff --git a/apps/backend/src/resolvers/types/Proposal.ts b/apps/backend/src/resolvers/types/Proposal.ts index f14402443e..5420c5dc4d 100644 --- a/apps/backend/src/resolvers/types/Proposal.ts +++ b/apps/backend/src/resolvers/types/Proposal.ts @@ -13,6 +13,7 @@ import { import { ResolverContext } from '../../context'; import { Proposal as ProposalOrigin, + InvitedProposal as InvitedProposalOrigin, ProposalEndStatus, ProposalPublicStatus, } from '../../models/Proposal'; @@ -101,6 +102,21 @@ export class Proposal implements Partial { public fileId?: string | null; } +@ObjectType() +export class InvitedProposal implements Partial { + @Field(() => String) + public proposalId: string; + + @Field(() => String) + public proposerName: string; + + @Field(() => String) + public title: string; + + @Field(() => String) + public abstract: string; +} + @Resolver(() => Proposal) export class ProposalResolver { @FieldResolver(() => [BasicUserDetails]) diff --git a/apps/backend/src/resolvers/types/User.ts b/apps/backend/src/resolvers/types/User.ts index 3767010019..104f80dbc8 100644 --- a/apps/backend/src/resolvers/types/User.ts +++ b/apps/backend/src/resolvers/types/User.ts @@ -21,6 +21,7 @@ import { UserExperimentsFilter } from '../queries/ExperimentsQuery'; import { Experiment } from './Experiment'; import { Fap } from './Fap'; import { Instrument } from './Instrument'; +import { Invite } from './Invite'; import { Proposal } from './Proposal'; import { Review } from './Review'; import { Role } from './Role'; @@ -165,6 +166,11 @@ export class UserResolver { ); } + @FieldResolver(() => [Invite]) + async coProposerInvites(@Root() user: User, @Ctx() context: ResolverContext) { + return context.queries.invite.getPendingCoProposerInvites(context.user); + } + @FieldResolver(() => [Experiment]) async experiments( @Root() user: User, diff --git a/apps/e2e/cypress/e2e/invites.cy.ts b/apps/e2e/cypress/e2e/invites.cy.ts index 4dc4f8e812..b1ce0821ae 100644 --- a/apps/e2e/cypress/e2e/invites.cy.ts +++ b/apps/e2e/cypress/e2e/invites.cy.ts @@ -243,7 +243,7 @@ context('Invites tests', () => { cy.getAndStoreFeaturesEnabled(); }); - it('Should be able to accept invite and then see that in log', function () { + it('Should be able to accept invite with the code and then see that in log', function () { if (!featureFlags.getEnabledFeatures().get(FeatureId.EMAIL_INVITE)) { this.skip(); } @@ -490,4 +490,56 @@ context('Invites tests', () => { .should('exist'); }); }); + + describe('Accepting co-proposer invites without code', () => { + beforeEach(() => { + cy.resetDB(true); + cy.updateFeature({ + action: FeatureUpdateAction.DISABLE, + featureIds: [FeatureId.EMAIL_INVITE_LEGACY], + }); + cy.getAndStoreFeaturesEnabled(); + }); + + it('Should be able to view and accept the outstanding invites with the proposal information', function () { + if (!featureFlags.getEnabledFeatures().get(FeatureId.EMAIL_INVITE)) { + this.skip(); + } + + cy.login('user3', initialDBData.roles.user); + cy.visit('/'); + + // Check notification box appears + cy.get('[data-testid="proposal-invite-notification"]').should('exist'); + cy.get('[data-testid="proposal-invite-notification"]').should( + 'contain.text', + 'outstanding invitation' + ); + // Proposal should NOT be in the table before accepting + cy.get('[data-cy="proposal-table"]').should('exist'); + cy.get('[data-cy="proposal-table"]').should( + 'not.contain.text', + initialDBData.proposal.title + ); + cy.get('[data-testid="view-invitations-btn"]').click(); + // Dialog should open and show proposal info + cy.get('[data-testid="proposal-invite-dialog"]').should('exist'); + cy.get('[data-testid="proposal-invite-dialog"]').should( + 'contain.text', + initialDBData.proposal.title + ); + cy.get('[data-testid="proposal-invite-dialog"]').should( + 'contain.text', + initialDBData.users.user1.firstName + ); + cy.get(`[data-testid=accept-invite-btn-1]`).click(); + // Success snackbar and dialog closes + cy.get('.SnackbarItem-variantSuccess').should('exist'); + // Proposal should now be in the table after accepting + cy.get('[data-cy="proposal-table"]').should( + 'contain.text', + initialDBData.proposal.title + ); + }); + }); }); diff --git a/apps/frontend/src/components/proposal/AcceptInvite.tsx b/apps/frontend/src/components/proposal/AcceptInviteWithCode.tsx similarity index 92% rename from apps/frontend/src/components/proposal/AcceptInvite.tsx rename to apps/frontend/src/components/proposal/AcceptInviteWithCode.tsx index da560d7893..516803d1af 100644 --- a/apps/frontend/src/components/proposal/AcceptInvite.tsx +++ b/apps/frontend/src/components/proposal/AcceptInviteWithCode.tsx @@ -9,13 +9,13 @@ import { FeatureContext } from 'context/FeatureContextProvider'; import { FeatureId } from 'generated/sdk'; import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; -interface AcceptInviteProps { +interface AcceptInviteWithCodeProps { title?: string; onAccepted?: () => void; onClose?: () => void; } -function AcceptInvite(props: AcceptInviteProps) { +function AcceptInviteWithCode(props: AcceptInviteWithCodeProps) { const { api } = useDataApiWithFeedback(); const { featuresMap } = useContext(FeatureContext); @@ -41,7 +41,7 @@ function AcceptInvite(props: AcceptInviteProps) { }); } else { api({ toastSuccessMessage: 'Code verification successful' }) - .acceptInvite({ code: values.code }) + .acceptInviteWithCode({ code: values.code }) .then(() => { setSuccessfullyAccepted(true); props.onAccepted?.(); @@ -82,4 +82,4 @@ function AcceptInvite(props: AcceptInviteProps) { ); } -export default AcceptInvite; +export default AcceptInviteWithCode; diff --git a/apps/frontend/src/components/proposal/ParticipantSelector.tsx b/apps/frontend/src/components/proposal/ParticipantSelector.tsx index 890edd7f57..10d4478ca5 100644 --- a/apps/frontend/src/components/proposal/ParticipantSelector.tsx +++ b/apps/frontend/src/components/proposal/ParticipantSelector.tsx @@ -218,6 +218,7 @@ function ParticipantSelector({ createdByUserId: 0, isEmailSent: false, expiresAt: null, + proposal: null, }; }; const handleSubmit = () => { diff --git a/apps/frontend/src/components/proposal/ProposalInviteNotification.tsx b/apps/frontend/src/components/proposal/ProposalInviteNotification.tsx new file mode 100644 index 0000000000..aa83469d4d --- /dev/null +++ b/apps/frontend/src/components/proposal/ProposalInviteNotification.tsx @@ -0,0 +1,166 @@ +import PersonAddIcon from '@mui/icons-material/PersonAdd'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import { alpha } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; +import { useSnackbar } from 'notistack'; +import React, { useState } from 'react'; + +import { useProposalInvites } from 'hooks/invite/useProposalInvites'; + +const ProposalInviteNotification = ({ onAccept }: { onAccept: () => void }) => { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const { + proposalInvites, + loading, + processingInviteId, + acceptCoProposerInvite, + } = useProposalInvites(); + const { enqueueSnackbar } = useSnackbar(); + + if (proposalInvites.length === 0) { + return null; + } + + const inviteCount = proposalInvites.length; + + const handleAcceptCoProposerInvite = async (inviteId: number) => { + try { + await acceptCoProposerInvite(inviteId); + enqueueSnackbar( + `Invitation for the Proposal "${proposalInvites.find((invite) => invite.id === inviteId)?.proposal?.title || ''}" accepted successfully.`, + { + variant: 'success', + } + ); + onAccept(); + } catch (error) { + enqueueSnackbar( + `Failed to accept the invitation for the proposal "${proposalInvites.find((invite) => invite.id === inviteId)?.proposal?.title || ''}". Please try again later.`, + { + variant: 'error', + } + ); + } + }; + + return ( + <> + alpha(theme.palette.info.main, 0.12), + border: (theme) => `1px solid ${alpha(theme.palette.info.main, 0.5)}`, + color: 'info.main', + borderRadius: 1, + padding: 2, + marginBottom: 2, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + }} + > + + + + You have {inviteCount} outstanding invitation + {inviteCount > 1 ? 's' : ''} to join proposal + {inviteCount > 1 ? 's' : ''}. + + + + + + setIsDialogOpen(false)} + maxWidth="md" + fullWidth + > + + Proposal Invitations + + + {proposalInvites.length === 0 ? ( + + No pending invitations found. + + ) : ( + proposalInvites.map((invite) => ( + <> + {invite.proposal && ( + + alpha(theme.palette.info.main, 0.12), + border: (theme) => + `1px solid ${alpha(theme.palette.info.main, 0.5)}`, + color: 'info.main', + borderRadius: 1, + padding: 2, + marginBottom: 2, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + }} + > +
+ + {invite.proposal.title || 'No Title'} + + + Principal Investigator: {invite.proposal.proposerName} + + + Invited on: + {new Date(invite.createdAt).toLocaleDateString()} + +
+
+ +
+
+ )} + + )) + )} +
+ + + +
+ + ); +}; + +export default ProposalInviteNotification; diff --git a/apps/frontend/src/components/proposal/ProposalTable.tsx b/apps/frontend/src/components/proposal/ProposalTable.tsx index c1b9de5e06..83cf453cbd 100644 --- a/apps/frontend/src/components/proposal/ProposalTable.tsx +++ b/apps/frontend/src/components/proposal/ProposalTable.tsx @@ -29,7 +29,7 @@ import { timeAgo } from 'utils/Time'; import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; import withConfirm, { WithConfirmType } from 'utils/withConfirm'; -import AcceptInvite from './AcceptInvite'; +import AcceptInviteWithCode from './AcceptInviteWithCode'; import CallSelectModalOnProposalsClone from './CallSelectModalOnProposalClone'; import DataAccessUsersModal from './DataAccessUsersModal'; import { ProposalStatusDefaultShortCodes } from './ProposalsSharedConstants'; @@ -353,7 +353,7 @@ const ProposalTable = ({ startIcon={} title="Join proposal" > - { searchQuery().then((data) => { if (data) { diff --git a/apps/frontend/src/components/proposal/ProposalTableUser.tsx b/apps/frontend/src/components/proposal/ProposalTableUser.tsx index 95cc18129b..4848842561 100644 --- a/apps/frontend/src/components/proposal/ProposalTableUser.tsx +++ b/apps/frontend/src/components/proposal/ProposalTableUser.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useState } from 'react'; +import ProposalInviteNotification from 'components/proposal/ProposalInviteNotification'; import { Call, Maybe, ProposalPublicStatus, Status } from 'generated/sdk'; import { useDataApi } from 'hooks/common/useDataApi'; @@ -40,7 +41,7 @@ export type UserProposalDataType = { const ProposalTableUser = () => { const api = useDataApi(); const [loading, setLoading] = useState(false); - + const [refreshTableKey, setRefreshTableKey] = useState(0); const sendUserProposalRequest = useCallback(async () => { setLoading(true); @@ -83,12 +84,18 @@ const ProposalTableUser = () => { }, [api]); return ( - + <> + setRefreshTableKey((prev) => prev + 1)} + /> + + ); }; diff --git a/apps/frontend/src/graphql/invite/acceptCoProposerInvite.graphql b/apps/frontend/src/graphql/invite/acceptCoProposerInvite.graphql new file mode 100644 index 0000000000..d47b2c91a5 --- /dev/null +++ b/apps/frontend/src/graphql/invite/acceptCoProposerInvite.graphql @@ -0,0 +1,5 @@ +mutation acceptCoProposerInvite($proposalId: String!) { + acceptCoProposerInvite(proposalId: $proposalId) { + ...invite + } +} diff --git a/apps/frontend/src/graphql/invite/acceptInvite.graphql b/apps/frontend/src/graphql/invite/acceptInvite.graphql index e4447e713a..722d3ef372 100644 --- a/apps/frontend/src/graphql/invite/acceptInvite.graphql +++ b/apps/frontend/src/graphql/invite/acceptInvite.graphql @@ -1,5 +1,5 @@ -mutation acceptInvite($code: String!) { - acceptInvite(code: $code) { +mutation acceptInviteWithCode($code: String!) { + acceptInviteWithCode(code: $code) { ...invite } } diff --git a/apps/frontend/src/graphql/invite/fragment.invite.graphql b/apps/frontend/src/graphql/invite/fragment.invite.graphql index f28c01b246..8b7e43abc8 100644 --- a/apps/frontend/src/graphql/invite/fragment.invite.graphql +++ b/apps/frontend/src/graphql/invite/fragment.invite.graphql @@ -8,4 +8,10 @@ fragment invite on Invite { claimedByUserId isEmailSent expiresAt + proposal { + proposalId + proposerName + title + abstract + } } diff --git a/apps/frontend/src/graphql/proposal/fragment.invitedProposal.graphql b/apps/frontend/src/graphql/proposal/fragment.invitedProposal.graphql new file mode 100644 index 0000000000..7993bf8079 --- /dev/null +++ b/apps/frontend/src/graphql/proposal/fragment.invitedProposal.graphql @@ -0,0 +1,6 @@ +fragment invitedProposal on InvitedProposal { + abstract + proposalId + proposerName + title +} diff --git a/apps/frontend/src/graphql/user/getCoProposerInvites.graphql b/apps/frontend/src/graphql/user/getCoProposerInvites.graphql new file mode 100644 index 0000000000..62e5a34211 --- /dev/null +++ b/apps/frontend/src/graphql/user/getCoProposerInvites.graphql @@ -0,0 +1,18 @@ +query getCoProposerInvites { + me { + coProposerInvites { + id + code + email + createdAt + createdByUserId + claimedAt + claimedByUserId + isEmailSent + expiresAt + proposal { + ...invitedProposal + } + } + } +} diff --git a/apps/frontend/src/hooks/invite/useProposalInvites.ts b/apps/frontend/src/hooks/invite/useProposalInvites.ts new file mode 100644 index 0000000000..1aac30fac6 --- /dev/null +++ b/apps/frontend/src/hooks/invite/useProposalInvites.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; + +import { GetCoProposerInvitesQuery } from 'generated/sdk'; +import { useDataApi } from 'hooks/common/useDataApi'; + +export function useProposalInvites() { + const [proposalInvites, setProposalInvites] = useState< + NonNullable['coProposerInvites'] + >([]); + const [loading, setLoading] = useState(true); + const [processingInviteId, setProcessingInviteId] = useState( + null + ); + + const api = useDataApi(); + + useEffect(() => { + let unmounted = false; + + setLoading(true); + api() + .getCoProposerInvites() + .then((data) => { + if (unmounted) { + return; + } + if (data.me) setProposalInvites(data.me.coProposerInvites); + setLoading(false); + }); + + return () => { + unmounted = true; + }; + }, [api]); + + const acceptCoProposerInvite = (inviteId: number) => { + const proposalId = proposalInvites.find((invite) => invite.id === inviteId) + ?.proposal?.proposalId; + if (!proposalId) { + throw new Error('Failed to accept the invitation.'); + } + setProcessingInviteId(inviteId); + api() + .acceptCoProposerInvite({ proposalId }) + .then(({ acceptCoProposerInvite }) => { + setProposalInvites((invites) => + invites.filter((invite) => invite.id !== acceptCoProposerInvite.id) + ); + }) + .catch(() => { + throw new Error('Failed to accept the invitation.'); + }) + .finally(() => { + setProcessingInviteId(null); + }); + }; + + return { + loading, + proposalInvites, + acceptCoProposerInvite, + processingInviteId, + }; +}