Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add user email verification #23

Merged
merged 11 commits into from
Aug 14, 2024
29 changes: 29 additions & 0 deletions migration/1723583534955-AddUserEmailVerificationFields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddUserEmailVerificationFields1723583534955
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "user"
ADD "emailConfirmationToken" character varying,
ADD "emailConfirmationTokenExpiredAt" TIMESTAMP,
ADD "emailConfirmed" boolean DEFAULT false,
ADD "emailConfirmationSent" boolean DEFAULT false,
ADD "emailConfirmationSentAt" TIMESTAMP,
ADD "emailConfirmedAt" TIMESTAMP;
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "user"
DROP COLUMN "emailConfirmationToken",
DROP COLUMN "emailConfirmationTokenExpiredAt",
DROP COLUMN "emailConfirmed",
DROP COLUMN "emailConfirmationSent",
DROP COLUMN "emailConfirmationSentAt",
DROP COLUMN "emailConfirmedAt";
`);
}
}
9 changes: 9 additions & 0 deletions src/adapters/notifications/MockNotificationAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ export class MockNotificationAdapter implements NotificationAdapterInterface {
return Promise.resolve(undefined);
}

async sendUserEmailConfirmation(params: {
email: string;
user: User;
token: string;
}) {
logger.debug('MockNotificationAdapter sendUserEmailConfirmation', params);
return Promise.resolve(undefined);
}

userSuperTokensCritical(): Promise<void> {
return Promise.resolve(undefined);
}
Expand Down
6 changes: 6 additions & 0 deletions src/adapters/notifications/NotificationAdapterInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ export interface NotificationAdapterInterface {
token: string;
}): Promise<void>;

sendUserEmailConfirmation(params: {
email: string;
user: User;
token: string;
}): Promise<void>;

userSuperTokensCritical(params: {
user: User;
eventName: UserStreamBalanceWarning;
Expand Down
23 changes: 23 additions & 0 deletions src/adapters/notifications/NotificationCenterAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,29 @@ export class NotificationCenterAdapter implements NotificationAdapterInterface {
}
}

// todo: use different eventName specific to Qacc (to show correct icon and description)
// todo: add the new eventName to the notification service and add the schema to Ortto
async sendUserEmailConfirmation(params: {
email: string;
user: User;
token: string;
}): Promise<void> {
const { email, user, token } = params;
try {
await callSendNotification({
eventName: NOTIFICATIONS_EVENT_NAMES.SEND_EMAIL_CONFIRMATION,
segment: {
payload: {
email,
verificationLink: `${dappUrl}/verification/user/${user.walletAddress}/${token}`,
},
},
});
} catch (e) {
logger.error('sendUserEmailConfirmation >> error', e);
}
}

async userSuperTokensCritical(params: {
user: User;
eventName: UserStreamBalanceWarning;
Expand Down
24 changes: 24 additions & 0 deletions src/entities/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,30 @@ export class User extends BaseEntity {
@Field(_type => Float, { nullable: true })
activeQFMBDScore?: number;

@Field(_type => Boolean, { nullable: false })
@Column({ default: false })
emailConfirmed: boolean;

@Field(_type => String, { nullable: true })
@Column('text', { nullable: true })
emailConfirmationToken: string | null;

@Field(_type => Date, { nullable: true })
@Column('timestamptz', { nullable: true })
emailConfirmationTokenExpiredAt: Date | null;

@Field(_type => Boolean, { nullable: true })
@Column({ default: false })
emailConfirmationSent: boolean;

@Field(_type => Date, { nullable: true })
@Column({ type: 'timestamptz', nullable: true })
emailConfirmationSentAt: Date | null;

@Field(_type => Date, { nullable: true })
@Column({ type: 'timestamptz', nullable: true })
emailConfirmedAt: Date | null;

@Field(_type => Int, { nullable: true })
async donationsCount() {
return await Donation.createQueryBuilder('donation')
Expand Down
120 changes: 120 additions & 0 deletions src/repositories/userRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ import { User, UserRole } from '../entities/user';
import {
findAdminUserByEmail,
findAllUsers,
findUserByEmailConfirmationToken,
findUserById,
findUserByWalletAddress,
findUsersWhoDonatedToProjectExcludeWhoLiked,
findUsersWhoLikedProjectExcludeProjectOwner,
findUsersWhoSupportProject,
updateUserEmailConfirmationStatus,
updateUserEmailConfirmationToken,
} from './userRepository';
import { Reaction } from '../entities/reaction';

Expand Down Expand Up @@ -44,6 +47,19 @@ describe(
findUsersWhoDonatedToProjectTestCases,
);

describe(
'userRepository.findUserByEmailConfirmationToken',
findUserByEmailConfirmationTokenTestCases,
);
describe(
'userRepository.updateUserEmailConfirmationStatus',
updateUserEmailConfirmationStatusTestCases,
);
describe(
'userRepository.updateUserEmailConfirmationToken',
updateUserEmailConfirmationTokenTestCases,
);

function findUsersWhoDonatedToProjectTestCases() {
it('should find wallet addresses of who donated to a project, exclude who liked', async () => {
const project = await saveProjectDirectlyToDb(createProjectData());
Expand Down Expand Up @@ -489,3 +505,107 @@ function findUsersWhoSupportProjectTestCases() {
);
});
}

function findUserByEmailConfirmationTokenTestCases() {
it('should return a user if a valid email confirmation token is provided', async () => {
await User.create({
email: '[email protected]',
emailConfirmationToken: 'validToken123',
loginType: 'wallet',
}).save();

const foundUser = await findUserByEmailConfirmationToken('validToken123');
assert.isNotNull(foundUser);
assert.equal(foundUser!.email, '[email protected]');
assert.equal(foundUser!.emailConfirmationToken, 'validToken123');
});

it('should return null if no user is found with the provided email confirmation token', async () => {
const foundUser = await findUserByEmailConfirmationToken('invalidToken123');
assert.isNull(foundUser);
});
}

function updateUserEmailConfirmationStatusTestCases() {
it('should update the email confirmation status of a user', async () => {
const user = await User.create({
email: '[email protected]',
emailConfirmed: false,
emailConfirmationToken: 'validToken123',
loginType: 'wallet',
}).save();

await updateUserEmailConfirmationStatus({
userId: user.id,
emailConfirmed: true,
emailConfirmationTokenExpiredAt: null,
emailConfirmationToken: null,
emailConfirmationSentAt: null,
});

// Using findOne with options object
const updatedUser = await User.findOne({ where: { id: user.id } });
assert.isNotNull(updatedUser);
assert.isTrue(updatedUser!.emailConfirmed);
assert.isNull(updatedUser!.emailConfirmationToken);
});

it('should not update any user if the userId does not exist', async () => {
const result = await updateUserEmailConfirmationStatus({
userId: 999, // non-existent userId
emailConfirmed: true,
emailConfirmationTokenExpiredAt: null,
emailConfirmationToken: null,
emailConfirmationSentAt: null,
});

assert.equal(result.affected, 0); // No rows should be affected
});
}

function updateUserEmailConfirmationTokenTestCases() {
it('should update the email confirmation token and expiry date for a user', async () => {
const user = await User.create({
email: '[email protected]',
loginType: 'wallet',
}).save();

const newToken = 'newToken123';
const newExpiryDate = new Date(Date.now() + 3600 * 1000); // 1 hour from now
const sentAtDate = new Date();

await updateUserEmailConfirmationToken({
userId: user.id,
emailConfirmationToken: newToken,
emailConfirmationTokenExpiredAt: newExpiryDate,
emailConfirmationSentAt: sentAtDate,
});

// Using findOne with options object
const updatedUser = await User.findOne({ where: { id: user.id } });
assert.isNotNull(updatedUser);
assert.equal(updatedUser!.emailConfirmationToken, newToken);
assert.equal(
updatedUser!.emailConfirmationTokenExpiredAt!.getTime(),
newExpiryDate.getTime(),
);
assert.equal(
updatedUser!.emailConfirmationSentAt!.getTime(),
sentAtDate.getTime(),
);
});

it('should throw an error if the userId does not exist', async () => {
try {
await updateUserEmailConfirmationToken({
userId: 999, // non-existent userId
emailConfirmationToken: 'newToken123',
emailConfirmationTokenExpiredAt: new Date(),
emailConfirmationSentAt: new Date(),
});
assert.fail('Expected an error to be thrown');
} catch (error) {
assert.equal(error.message, 'User not found');
}
});
}
65 changes: 65 additions & 0 deletions src/repositories/userRepository.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { UpdateResult } from 'typeorm';
import { publicSelectionFields, User, UserRole } from '../entities/user';
import { Donation } from '../entities/donation';
import { Reaction } from '../entities/reaction';
Expand Down Expand Up @@ -177,3 +178,67 @@ export const findUsersWhoSupportProject = async (
}
return users;
};

export const findUserByEmailConfirmationToken = async (
emailConfirmationToken: string,
): Promise<User | null> => {
return User.createQueryBuilder('user')
.where({
emailConfirmationToken,
})
.getOne();
};

export const updateUserEmailConfirmationStatus = async (params: {
userId: number;
emailConfirmed: boolean;
emailConfirmationTokenExpiredAt: Date | null;
emailConfirmationToken: string | null;
emailConfirmationSentAt: Date | null;
}): Promise<UpdateResult> => {
const {
userId,
emailConfirmed,
emailConfirmationTokenExpiredAt,
emailConfirmationToken,
emailConfirmationSentAt,
} = params;

return User.createQueryBuilder()
.update(User)
.set({
emailConfirmed,
emailConfirmationTokenExpiredAt,
emailConfirmationToken,
emailConfirmationSentAt,
})
.where('id = :userId', { userId })
.execute();
};

export const updateUserEmailConfirmationToken = async (params: {
userId: number;
emailConfirmationToken: string;
emailConfirmationTokenExpiredAt: Date;
emailConfirmationSentAt: Date;
}): Promise<User> => {
const {
userId,
emailConfirmationToken,
emailConfirmationTokenExpiredAt,
emailConfirmationSentAt,
} = params;

const user = await findUserById(userId);
if (!user) {
throw new Error('User not found');
}

user.emailConfirmationToken = emailConfirmationToken;
user.emailConfirmationTokenExpiredAt = emailConfirmationTokenExpiredAt;
user.emailConfirmationSentAt = emailConfirmationSentAt;
user.emailConfirmed = false;

await user.save();
return user;
};
Loading
Loading