Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
309 changes: 271 additions & 38 deletions __tests__/userHotTake.ts → __tests__/hotTake.ts

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions src/common/vote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { GQLEmptyResponse } from '../schema/common';
import { ensureSourcePermissions } from '../schema/sources';
import { AuthContext } from '../Context';
import { UserComment } from '../entity/user/UserComment';
import { HotTake } from '../entity/user/HotTake';
import { UserHotTake } from '../entity/user/UserHotTake';
import { UserVote } from '../types';

type UserVoteProps = {
Expand Down Expand Up @@ -154,3 +156,35 @@ export const voteComment = async ({

return { _: true };
};

export const voteHotTake = async ({
ctx,
id,
vote,
}: UserVoteProps): Promise<GQLEmptyResponse> => {
try {
validateVoteType({ vote });

// Verify hot take exists
await ctx.con.getRepository(HotTake).findOneByOrFail({ id });

const userHotTakeRepo = ctx.con.getRepository(UserHotTake);

// Save vote (triggers handle upvotes count updates)
await userHotTakeRepo.save({
hotTakeId: id,
userId: ctx.userId,
vote,
});
} catch (originalError) {
const err = originalError as TypeORMQueryFailedError;

if (err?.code === TypeOrmError.FOREIGN_KEY) {
throw new NotFoundError('Hot take or user not found');
}

throw err;
}

return { _: true };
};
49 changes: 49 additions & 0 deletions src/entity/user/HotTake.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import type { User } from './User';

@Entity()
@Index('IDX_hot_take_user_id', ['userId'])
export class HotTake {
@PrimaryGeneratedColumn('uuid', {
primaryKeyConstraintName: 'PK_hot_take_id',
})
id: string;

@Column({ type: 'text' })
userId: string;

@Column({ type: 'text' })
emoji: string;

@Column({ type: 'text' })
title: string;

@Column({ type: 'text', nullable: true })
subtitle: string | null;

@Column({ type: 'integer' })
position: number;

@Column({ type: 'integer', default: 0 })
upvotes: number;

@Column({ type: 'timestamp', default: () => 'now()' })
createdAt: Date;

@ManyToOne('User', {
lazy: true,
onDelete: 'CASCADE',
})
@JoinColumn({
name: 'userId',
foreignKeyConstraintName: 'FK_hot_take_user_id',
})
user: Promise<User>;
}
44 changes: 27 additions & 17 deletions src/entity/user/UserHotTake.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,48 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import type { User } from './User';
import type { HotTake } from './HotTake';
import { UserVote } from '../../types';

@Entity()
@Index('IDX_user_hot_take_user_id', ['userId'])
@Index(['hotTakeId', 'userId'], { unique: true })
@Index(['userId', 'vote', 'votedAt'])
export class UserHotTake {
@PrimaryGeneratedColumn('uuid', {
primaryKeyConstraintName: 'PK_user_hot_take_id',
})
id: string;
@PrimaryColumn({ type: 'uuid' })
hotTakeId: string;

@Column({ type: 'text' })
@PrimaryColumn({ type: 'text' })
userId: string;

@Column({ type: 'text' })
emoji: string;
@CreateDateColumn()
createdAt: Date;

@Column({ type: 'text' })
title: string;
@UpdateDateColumn()
updatedAt: Date;

@Column({ type: 'text', nullable: true })
subtitle: string | null;
@Column({ default: null, nullable: true })
votedAt: Date;

@Column({ type: 'integer' })
position: number;
@Column({ type: 'smallint', default: UserVote.None })
vote: UserVote = UserVote.None;

@Column({ type: 'timestamp', default: () => 'now()' })
createdAt: Date;
@ManyToOne('HotTake', {
lazy: true,
onDelete: 'CASCADE',
})
@JoinColumn({
name: 'hotTakeId',
foreignKeyConstraintName: 'FK_user_hot_take_hot_take_id',
})
hotTake: Promise<HotTake>;

@ManyToOne('User', {
lazy: true,
Expand Down
1 change: 1 addition & 0 deletions src/entity/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from './UserMarketingCta';
export * from './UserStats';
export * from './UserTopReader';
export * from './UserStack';
export * from './HotTake';
export * from './UserHotTake';
export * from './UserWorkspacePhoto';
export * from './UserGear';
Expand Down
39 changes: 39 additions & 0 deletions src/graphorm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,31 @@ const existsByUserAndPost =
END`;
};

const existsByUserAndHotTake =
(entity: string, build?: (queryBuilder: QueryBuilder) => QueryBuilder) =>
(ctx: Context, alias: string, qb: QueryBuilder): string => {
let query = qb
.select('1')
.from(entity, 'a')
.where(`a."userId" = :upvoterUserId`, { upvoterUserId: ctx.userId })
.andWhere(`a."hotTakeId" = ${alias}.id`)
.limit(1);

if (typeof build === 'function') {
query = build(query);
}

return /*sql*/ `CASE
WHEN
${query.getQuery()}
IS NOT NULL
THEN
TRUE
ELSE
FALSE
END`;
};

const nullIfNotLoggedIn = <T>(value: T, ctx: Context): T | null =>
ctx.userId ? value : null;

Expand Down Expand Up @@ -2178,6 +2203,20 @@ const obj = new GraphORM({
},
},
},
HotTake: {
requiredColumns: ['id', 'userId'],
fields: {
upvoted: {
select: existsByUserAndHotTake('UserHotTake', (qb) =>
qb.andWhere(`${qb.alias}.vote = 1`),
),
transform: nullIfNotLoggedIn,
},
createdAt: {
transform: transformDate,
},
},
},
UserWorkspacePhoto: {
requiredColumns: ['id', 'userId', 'image'],
fields: {
Expand Down
149 changes: 149 additions & 0 deletions src/migration/1769156534090-AddUserHotTakeUpvotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

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

public async up(queryRunner: QueryRunner): Promise<void> {
// Rename user_hot_take to hot_take
await queryRunner.query(`ALTER TABLE "user_hot_take" RENAME TO "hot_take"`);
await queryRunner.query(`ALTER INDEX "IDX_user_hot_take_user_id" RENAME TO "IDX_hot_take_user_id"`);
await queryRunner.query(`ALTER TABLE "hot_take" RENAME CONSTRAINT "PK_user_hot_take_id" TO "PK_hot_take_id"`);
await queryRunner.query(`ALTER TABLE "hot_take" RENAME CONSTRAINT "FK_user_hot_take_user_id" TO "FK_hot_take_user_id"`);
await queryRunner.query(`ALTER TABLE "hot_take" ADD COLUMN "upvotes" integer NOT NULL DEFAULT 0`);

// Create new user_hot_take table (like user_post)
await queryRunner.query(`
CREATE TABLE "user_hot_take" (
"hotTakeId" uuid NOT NULL,
"userId" character varying NOT NULL,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
"votedAt" TIMESTAMP,
"vote" smallint NOT NULL DEFAULT 0,
CONSTRAINT "PK_user_hot_take" PRIMARY KEY ("hotTakeId", "userId")
)
`);
await queryRunner.query(`CREATE INDEX "IDX_user_hot_take_hotTakeId_userId" ON "user_hot_take" ("hotTakeId", "userId")`);
await queryRunner.query(`CREATE INDEX "IDX_user_hot_take_userId_vote_votedAt" ON "user_hot_take" ("userId", "vote", "votedAt")`);

await queryRunner.query(`
ALTER TABLE "user_hot_take"
ADD CONSTRAINT "FK_user_hot_take_hot_take_id"
FOREIGN KEY ("hotTakeId")
REFERENCES "hot_take"("id")
ON DELETE CASCADE
`);
await queryRunner.query(`
ALTER TABLE "user_hot_take"
ADD CONSTRAINT "FK_user_hot_take_user_id"
FOREIGN KEY ("userId")
REFERENCES "user"("id")
ON DELETE CASCADE
`);

// Create votedAt trigger function
await queryRunner.query(`
CREATE OR REPLACE FUNCTION hot_take_voted_at_time()
RETURNS TRIGGER AS $$
BEGIN
NEW."votedAt" = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await queryRunner.query(`
CREATE TRIGGER user_hot_take_voted_at_trigger
BEFORE INSERT OR UPDATE ON user_hot_take
FOR EACH ROW
WHEN (NEW.vote IS DISTINCT FROM 0)
EXECUTE FUNCTION hot_take_voted_at_time();
`);

// Create vote insert trigger
await queryRunner.query(`
CREATE OR REPLACE FUNCTION user_hot_take_vote_insert_trigger_function()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.vote = 1 THEN
UPDATE hot_take SET upvotes = upvotes + 1 WHERE id = NEW."hotTakeId";
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await queryRunner.query(`
CREATE TRIGGER user_hot_take_vote_insert_trigger
AFTER INSERT ON user_hot_take
FOR EACH ROW
EXECUTE FUNCTION user_hot_take_vote_insert_trigger_function();
`);

// Create vote update trigger
await queryRunner.query(`
CREATE OR REPLACE FUNCTION user_hot_take_vote_update_trigger_function()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.vote IS DISTINCT FROM NEW.vote THEN
IF OLD.vote = 0 AND NEW.vote = 1 THEN
UPDATE hot_take SET upvotes = upvotes + 1 WHERE id = NEW."hotTakeId";
ELSIF OLD.vote = 1 AND NEW.vote = 0 THEN
UPDATE hot_take SET upvotes = upvotes - 1 WHERE id = NEW."hotTakeId";
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await queryRunner.query(`
CREATE TRIGGER user_hot_take_vote_update_trigger
AFTER UPDATE ON user_hot_take
FOR EACH ROW
EXECUTE FUNCTION user_hot_take_vote_update_trigger_function();
`);

// Create vote delete trigger
await queryRunner.query(`
CREATE OR REPLACE FUNCTION user_hot_take_vote_delete_trigger_function()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.vote = 1 THEN
UPDATE hot_take SET upvotes = upvotes - 1 WHERE id = OLD."hotTakeId";
END IF;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
`);
await queryRunner.query(`
CREATE TRIGGER user_hot_take_vote_delete_trigger
AFTER DELETE ON user_hot_take
FOR EACH ROW
EXECUTE FUNCTION user_hot_take_vote_delete_trigger_function();
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Drop triggers
await queryRunner.query('DROP TRIGGER IF EXISTS user_hot_take_vote_delete_trigger ON user_hot_take');
await queryRunner.query('DROP FUNCTION IF EXISTS user_hot_take_vote_delete_trigger_function');
await queryRunner.query('DROP TRIGGER IF EXISTS user_hot_take_vote_update_trigger ON user_hot_take');
await queryRunner.query('DROP FUNCTION IF EXISTS user_hot_take_vote_update_trigger_function');
await queryRunner.query('DROP TRIGGER IF EXISTS user_hot_take_vote_insert_trigger ON user_hot_take');
await queryRunner.query('DROP FUNCTION IF EXISTS user_hot_take_vote_insert_trigger_function');
await queryRunner.query('DROP TRIGGER IF EXISTS user_hot_take_voted_at_trigger ON user_hot_take');
await queryRunner.query('DROP FUNCTION IF EXISTS hot_take_voted_at_time');

// Drop user_hot_take table
await queryRunner.query('ALTER TABLE "user_hot_take" DROP CONSTRAINT "FK_user_hot_take_user_id"');
await queryRunner.query('ALTER TABLE "user_hot_take" DROP CONSTRAINT "FK_user_hot_take_hot_take_id"');
await queryRunner.query('DROP INDEX "IDX_user_hot_take_userId_vote_votedAt"');
await queryRunner.query('DROP INDEX "IDX_user_hot_take_hotTakeId_userId"');
await queryRunner.query('DROP TABLE "user_hot_take"');

// Rename hot_take back to user_hot_take
await queryRunner.query('ALTER TABLE "hot_take" RENAME CONSTRAINT "FK_hot_take_user_id" TO "FK_user_hot_take_user_id"');
await queryRunner.query('ALTER TABLE "hot_take" RENAME CONSTRAINT "PK_hot_take_id" TO "PK_user_hot_take_id"');
await queryRunner.query('ALTER INDEX "IDX_hot_take_user_id" RENAME TO "IDX_user_hot_take_user_id"');
await queryRunner.query(`ALTER TABLE "hot_take" DROP COLUMN "upvotes"`);
await queryRunner.query('ALTER TABLE "hot_take" RENAME TO "user_hot_take"');
}
}
Loading
Loading