Skip to content

Commit

Permalink
Merge pull request #23 from GeneralMagicio/addUserEmailVerification
Browse files Browse the repository at this point in the history
Add user email verification
  • Loading branch information
ae2079 authored Aug 14, 2024
2 parents febeaa4 + 99b640d commit 20b9959
Show file tree
Hide file tree
Showing 13 changed files with 607 additions and 2 deletions.
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

0 comments on commit 20b9959

Please sign in to comment.