Skip to content

Commit

Permalink
Add overall improvements and minor bugs fixes (#11)
Browse files Browse the repository at this point in the history
* feat: add reenableClaim mutation

* feat: notify newly added claim attributions

* chore: remove unused vars

* feat: notify newly added knowledge bit attributions

* feat: require email confirmation on email update

* feat: replace magic link by sign in code

* feat: add claimsByTag query

* feat: save tweet hashtags as FF tags

* feat: add database migrations

* feat: send all matching entries on tags search

* chore: remove generate-typings function
  • Loading branch information
blocknomad authored Jan 27, 2022
1 parent 9b9d3f0 commit fed1616
Show file tree
Hide file tree
Showing 29 changed files with 604 additions and 133 deletions.
32 changes: 32 additions & 0 deletions ormconfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import fs from 'fs';
import { join } from 'path';
import dotenv from 'dotenv';

dotenv.config();

const ormConfig: TypeOrmModuleOptions = {
type: 'postgres',
host: process.env.POSTGRES_HOST,
port: Number(process.env.POSTGRES_PORT),
username: process.env.POSTGRES_USERNAME,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DATABASE,
entities: [process.env.POSTGRES_ENTITIES],
synchronize: process.env.POSTGRES_SYNCHRONIZE === 'true',
logging: process.env.POSTGRES_LOGGING === 'true',
migrations: [process.env.POSTGRES_MIGRATIONS],
migrationsTableName: process.env.POSTGRES_MIGRATIONS_TABLE_NAME,
migrationsRun: process.env.POSTGRES_MIGRATIONS_RUN === 'true',
ssl: {
rejectUnauthorized: true,
ca: fs
.readFileSync(join(process.cwd(), 'certs/ca-certificate.crt'))
.toString(),
},
cli: {
migrationsDir: './src/migrations',
},
};

export default ormConfig;
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"typeorm:migration:create": "yarn typeorm migration:generate -- -n",
"typeorm:migration:run": "yarn typeorm migration:run"
},
"dependencies": {
"@nestjs/common": "^8.0.0",
Expand Down Expand Up @@ -65,6 +68,7 @@
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"dotenv": "^14.3.2",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
Expand Down
Empty file removed scripts/generate-typings.ts
Empty file.
22 changes: 3 additions & 19 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { join } from 'path';
import fs from 'fs';
import { ScheduleModule } from '@nestjs/schedule';
import { join } from 'path';

import { UsersModule } from './modules/users/users.module';
import { AuthModule } from './modules/auth/auth.module';
Expand All @@ -20,6 +19,7 @@ import { ArgumentCommentsModule } from './modules/argument-comments/argument-com
import { OpinionsModule } from './modules/opinions/opinions.module';
import { TwitterModule } from './modules/twitter/twitter.module';
import { FrontendModule } from './modules/frontend/frontend.module';
import typeormConfig from '../ormconfig';

@Module({
imports: [
Expand All @@ -41,23 +41,7 @@ import { FrontendModule } from './modules/frontend/frontend.module';
credentials: true,
},
}),
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.POSTGRES_HOST,
port: Number(process.env.POSTGRES_PORT),
username: process.env.POSTGRES_USERNAME,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DATABASE,
entities: [process.env.POSTGRES_ENTITIES],
synchronize: Boolean(process.env.POSTGRES_SYNCHRONIZE),
logging: Boolean(process.env.POSTGRES_LOGGING),
ssl: {
rejectUnauthorized: true,
ca: fs
.readFileSync(join(process.cwd(), 'certs/ca-certificate.crt'))
.toString(),
},
}),
TypeOrmModule.forRoot(typeormConfig),
ScheduleModule.forRoot(),
UsersModule,
AuthModule,
Expand Down
14 changes: 10 additions & 4 deletions src/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export interface SaveSourceInput {
export interface SaveTagInput {
id?: Nullable<string>;
label: string;
slug?: Nullable<string>;
}

export interface SignInWithEthereumInput {
Expand Down Expand Up @@ -327,6 +328,7 @@ export interface IMutation {
deleteKnowledgeBit(id: string): boolean | Promise<boolean>;
disableClaim(id: string): boolean | Promise<boolean>;
inviteFriends(inviteFriendsInput: InviteFriendsInput): boolean | Promise<boolean>;
reenableClaim(id: string): boolean | Promise<boolean>;
removeAPIKey(): boolean | Promise<boolean>;
removeArgument(id: number): Argument | Promise<Argument>;
removeAttribution(id: number): Attribution | Promise<Attribution>;
Expand All @@ -337,17 +339,18 @@ export interface IMutation {
requestTwitterOAuthUrl(callbackUrl: string): string | Promise<string>;
saveKnowledgeBitVote(knowledgeBitId: string, type: KnowledgeBitVoteTypes): boolean | Promise<boolean>;
saveOpinion(saveOpinionInput: SaveOpinionInput): Opinion | Promise<Opinion>;
sendMagicLink(email: string): boolean | Promise<boolean>;
sendSignInCode(email: string): boolean | Promise<boolean>;
sendUpdateEmailVerificationCode(email: string): boolean | Promise<boolean>;
signInWithEthereum(signInWithEthereumInput: SignInWithEthereumInput): User | Promise<User>;
signOut(): boolean | Promise<boolean>;
updateArgument(updateArgumentInput: UpdateArgumentInput): Argument | Promise<Argument>;
updateArgumentComment(updateArgumentCommentInput: UpdateArgumentCommentInput): ArgumentComment | Promise<ArgumentComment>;
updateClaim(updateClaimInput: UpdateClaimInput): Claim | Promise<Claim>;
updateEmail(email: string): User | Promise<User>;
updateEmail(verificationCode: string): boolean | Promise<boolean>;
updateKnowledgeBit(updateKnowledgeBitInput: UpdateKnowledgeBitInput): KnowledgeBit | Promise<KnowledgeBit>;
updateProfile(updateProfileInput: UpdateProfileInput): User | Promise<User>;
validateTwitterOAuth(oauthToken: string, oauthVerifier: string): string | Promise<string>;
verifyMagicLink(hash: string): User | Promise<User>;
verifySignInCode(signInCode: string): User | Promise<User>;
}

export interface Opinion {
Expand Down Expand Up @@ -379,6 +382,8 @@ export interface IQuery {
attributions(): Attribution[] | Promise<Attribution[]>;
claim(slug: string): Claim | Promise<Claim>;
claims(limit: number, offset: number): PaginatedClaims | Promise<PaginatedClaims>;
claimsByTag(limit: number, offset: number, tag: string): PaginatedClaims | Promise<PaginatedClaims>;
disabledClaims(limit: number, offset: number): PaginatedClaims | Promise<PaginatedClaims>;
knowledgeBit(id: string): KnowledgeBit | Promise<KnowledgeBit>;
knowledgeBits(claimSlug: string): KnowledgeBit[] | Promise<KnowledgeBit[]>;
nonce(): string | Promise<string>;
Expand All @@ -390,7 +395,7 @@ export interface IQuery {
session(): Session | Promise<Session>;
source(id: number): Source | Promise<Source>;
sources(): Source[] | Promise<Source[]>;
tag(id: number): Tag | Promise<Tag>;
tag(slug: string): Tag | Promise<Tag>;
trendingClaims(limit: number, offset: number): PaginatedClaims | Promise<PaginatedClaims>;
userClaims(username: string): Claim[] | Promise<Claim[]>;
userContributedClaims(username: string): Claim[] | Promise<Claim[]>;
Expand Down Expand Up @@ -430,6 +435,7 @@ export interface Tag {
createdAt: string;
id: string;
label: string;
slug: string;
updatedAt: string;
}

Expand Down
55 changes: 55 additions & 0 deletions src/migrations/1643311787893-tag-slug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import slugify from 'slugify';
import { MigrationInterface, QueryRunner } from 'typeorm';

import { Tag } from 'src/modules/tags/entities/tag.entity';

export class tagSlug1643311787893 implements MigrationInterface {
name = 'tagSlug1643311787893';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "magicLinkHash"`);
await queryRunner.query(
`ALTER TABLE "tag" ADD "slug" character varying NOT NULL DEFAULT 'x'`,
);

await queryRunner.startTransaction();
const tags = await queryRunner.manager.find(Tag);
const updatedTags = tags.reduce((acc, curr) => {
const baseSlug = slugify(curr.label, {
lower: true,
strict: true,
});
let slug;
let slugIndex = 0;

do {
slug = `${baseSlug}${slugIndex > 0 ? `-${slugIndex}` : ''}`;
slugIndex++;
} while (
tags.find((tag) => tag.slug === slug) !== undefined ||
acc.find((tag) => tag.slug === slug) !== undefined
);

const tag = queryRunner.manager.create(Tag, { ...curr, slug });

return [...acc, tag];
}, []);

await queryRunner.manager.save(updatedTags);
await queryRunner.commitTransaction();

await queryRunner.query(
`ALTER TABLE "tag" ADD CONSTRAINT "UQ_3413aed3ecde54f832c4f44f045" UNIQUE ("slug")`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "tag" DROP CONSTRAINT "UQ_3413aed3ecde54f832c4f44f045"`,
);
await queryRunner.query(`ALTER TABLE "tag" DROP COLUMN "slug"`);
await queryRunner.query(
`ALTER TABLE "user" ADD "magicLinkHash" character varying`,
);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Resolver, Query, Mutation, Args, Int } from '@nestjs/graphql';
import { Resolver, Mutation, Args } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';

import { ArgumentCommentsService } from './argument-comments.service';
Expand Down
2 changes: 1 addition & 1 deletion src/modules/arguments/entities/argument.entity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ObjectType, Field, Int, registerEnumType } from '@nestjs/graphql';
import { ObjectType, Field, registerEnumType } from '@nestjs/graphql';
import {
Column,
Entity,
Expand Down
38 changes: 37 additions & 1 deletion src/modules/attributions/attributions.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { sendMail } from 'src/common/services/mail';
import { Repository } from 'typeorm';

import { SaveAttributionInput } from './dto/save-attribution.input';
Expand All @@ -13,7 +14,10 @@ export class AttributionsService {
) {}

async save(saveAttributionInput: SaveAttributionInput[]) {
return await this.attributionsRepository.save(saveAttributionInput || []);
return await this.attributionsRepository.upsert(
saveAttributionInput || [],
['identifier'],
);
}

async upsert(saveAttributionInput: SaveAttributionInput[]) {
Expand All @@ -34,4 +38,36 @@ export class AttributionsService {
remove(id: number) {
return `This action removes a #${id} attribution`;
}

async notifyNewlyAdded({
attributions = [],
existing = [],
subject,
html,
}: {
attributions: Attribution[];
existing?: Attribution[];
subject: string;
html: string;
}) {
const newlyAddedAttributionsByEmail = attributions
.filter(
({ origin, identifier }) =>
origin === 'email' &&
existing.find(
(attribution) => attribution.identifier === identifier,
) === undefined,
)
.map(({ identifier }) => identifier);

if (newlyAddedAttributionsByEmail.length > 0) {
await sendMail({
to: newlyAddedAttributionsByEmail,
subject,
html,
});
}

return true;
}
}
19 changes: 19 additions & 0 deletions src/modules/auth/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

import { UserRole } from '../users/entities/user.entity';

export class SessionGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean | Promise<boolean> {
const ctx = GqlExecutionContext.create(context);
Expand All @@ -19,3 +21,20 @@ export class SessionGuard implements CanActivate {
}
}
}

export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean | Promise<boolean> {
const ctx = GqlExecutionContext.create(context);
const { req } = ctx.getContext();

try {
const user = req.session.user || req.user;

if (user && user.role === UserRole.ADMIN) {
return true;
}
} catch (e) {
throw new UnauthorizedException();
}
}
}
52 changes: 29 additions & 23 deletions src/modules/auth/auth.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,55 +85,61 @@ export class AuthResolver {
}

@Mutation(() => Boolean)
async sendMagicLink(@Args('email') email: string) {
const hash = crypto.randomBytes(24).toString('hex');
async sendSignInCode(@Args('email') email: string, @Context() context) {
const signInCode = new Date().getTime().toString().substring(7);

await this.authService.sendMagicLink({
await this.authService.sendSignInCode({
email,
hash,
signInCode,
});

const user = await this.usersService.findOne({
where: { email },
});

if (user) {
await this.usersService.save({
id: user.id,
magicLinkHash: hash,
});
} else {
if (!user) {
await this.usersService.create({
email,
username: email,
usernameSource: UsernameSource.CUSTOM,
avatar: getGravatarURL(email),
avatarSource: AvatarSource.GRAVATAR,
magicLinkHash: hash,
});
}

if (context.req.session) {
context.req.session.signInWithEmail = {
email,
code: signInCode,
};
}

return true;
}

@Mutation(() => User)
async verifyMagicLink(@Args('hash') hash: string, @Context() context) {
async verifySignInCode(
@Args('signInCode') signInCode: string,
@Context() context,
) {
const { email, code: expectedSignInCode } =
context.req.session.signInWithEmail || {};

if (!expectedSignInCode) {
throw new Error('Unable to check sign in code');
} else if (expectedSignInCode !== signInCode.trim()) {
throw new Error('Invalid sign in code');
}

const user = await this.usersService.findOne({
where: { magicLinkHash: hash },
where: { email },
});

if (user) {
if (context.req.session) {
context.req.session.user = user;

this.usersService.save({
id: user.id,
magicLinkHash: '',
});

return user;
} else {
throw new Error('Invalid magic link');
}

return user;
}

@Mutation(() => Boolean)
Expand Down
Loading

0 comments on commit fed1616

Please sign in to comment.