diff --git a/__tests__/userHotTake.ts b/__tests__/hotTake.ts similarity index 51% rename from __tests__/userHotTake.ts rename to __tests__/hotTake.ts index d29ed9a852..41432e1494 100644 --- a/__tests__/userHotTake.ts +++ b/__tests__/hotTake.ts @@ -10,7 +10,9 @@ import { } from './helpers'; import { User } from '../src/entity/user/User'; import { usersFixture } from './fixture/user'; +import { HotTake } from '../src/entity/user/HotTake'; import { UserHotTake } from '../src/entity/user/UserHotTake'; +import { UserVote } from '../src/types'; let con: DataSource; let state: GraphQLTestingState; @@ -32,10 +34,10 @@ beforeEach(async () => { await saveFixtures(con, User, usersFixture); }); -describe('query userHotTakes', () => { +describe('query hotTakes', () => { const QUERY = ` - query UserHotTakes($userId: ID!) { - userHotTakes(userId: $userId) { + query HotTakes($userId: ID!) { + hotTakes(userId: $userId) { edges { node { id @@ -51,23 +53,23 @@ describe('query userHotTakes', () => { it('should return empty list for user with no hot takes', async () => { const res = await client.query(QUERY, { variables: { userId: '1' } }); - expect(res.data.userHotTakes.edges).toEqual([]); + expect(res.data.hotTakes.edges).toEqual([]); }); it('should return hot takes ordered by position', async () => { - await con.getRepository(UserHotTake).save([ + await con.getRepository(HotTake).save([ { userId: '1', emoji: '🔥', title: 'Hot take 1', position: 1 }, { userId: '1', emoji: '💡', title: 'Hot take 2', position: 0 }, ]); const res = await client.query(QUERY, { variables: { userId: '1' } }); - expect(res.data.userHotTakes.edges).toHaveLength(2); - expect(res.data.userHotTakes.edges[0].node.title).toBe('Hot take 2'); - expect(res.data.userHotTakes.edges[1].node.title).toBe('Hot take 1'); + expect(res.data.hotTakes.edges).toHaveLength(2); + expect(res.data.hotTakes.edges[0].node.title).toBe('Hot take 2'); + expect(res.data.hotTakes.edges[1].node.title).toBe('Hot take 1'); }); it('should return hot takes with subtitle', async () => { - await con.getRepository(UserHotTake).save({ + await con.getRepository(HotTake).save({ userId: '1', emoji: '🎯', title: 'My opinion', @@ -76,7 +78,7 @@ describe('query userHotTakes', () => { }); const res = await client.query(QUERY, { variables: { userId: '1' } }); - expect(res.data.userHotTakes.edges[0].node).toMatchObject({ + expect(res.data.hotTakes.edges[0].node).toMatchObject({ emoji: '🎯', title: 'My opinion', subtitle: 'Some context', @@ -84,10 +86,241 @@ describe('query userHotTakes', () => { }); }); -describe('mutation addUserHotTake', () => { +describe('query hotTakes with upvotes', () => { + const QUERY = ` + query HotTakes($userId: ID!) { + hotTakes(userId: $userId) { + edges { + node { + id + title + upvotes + upvoted + } + } + } + } + `; + + it('should return upvotes count', async () => { + const hotTake = await con.getRepository(HotTake).save({ + userId: '1', + emoji: '🔥', + title: 'Popular take', + position: 0, + }); + + await con.getRepository(UserHotTake).save([ + { hotTakeId: hotTake.id, userId: '2', vote: UserVote.Up }, + { hotTakeId: hotTake.id, userId: '3', vote: UserVote.Up }, + ]); + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.data.hotTakes.edges[0].node.upvotes).toBe(2); + }); + + it('should return upvoted as null when not logged in', async () => { + await con.getRepository(HotTake).save({ + userId: '1', + emoji: '🔥', + title: 'Take', + position: 0, + }); + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.data.hotTakes.edges[0].node.upvoted).toBeNull(); + }); + + it('should return upvoted as true when user upvoted', async () => { + loggedUser = '2'; + const hotTake = await con.getRepository(HotTake).save({ + userId: '1', + emoji: '🔥', + title: 'Take', + position: 0, + }); + + await con.getRepository(UserHotTake).save({ + hotTakeId: hotTake.id, + userId: '2', + vote: UserVote.Up, + }); + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.data.hotTakes.edges[0].node.upvoted).toBe(true); + }); + + it('should return upvoted as false when user has not upvoted', async () => { + loggedUser = '2'; + await con.getRepository(HotTake).save({ + userId: '1', + emoji: '🔥', + title: 'Take', + position: 0, + }); + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.data.hotTakes.edges[0].node.upvoted).toBe(false); + }); +}); + +describe('mutation vote on hot take', () => { + const MUTATION = ` + mutation Vote($id: ID!, $entity: UserVoteEntity!, $vote: Int!) { + vote(id: $id, entity: $entity, vote: $vote) { + _ + } + } + `; + + it('should require authentication', async () => { + const res = await client.mutate(MUTATION, { + variables: { + id: '00000000-0000-0000-0000-000000000000', + entity: 'hot_take', + vote: 1, + }, + }); + expect(res.errors?.[0]?.extensions?.code).toBe('UNAUTHENTICATED'); + }); + + it('should upvote a hot take', async () => { + loggedUser = '2'; + const hotTake = await con.getRepository(HotTake).save({ + userId: '1', + emoji: '🔥', + title: 'Take', + position: 0, + }); + + const res = await client.mutate(MUTATION, { + variables: { + id: hotTake.id, + entity: 'hot_take', + vote: 1, + }, + }); + + expect(res.errors).toBeUndefined(); + + const userHotTake = await con.getRepository(UserHotTake).findOneBy({ + hotTakeId: hotTake.id, + userId: '2', + }); + expect(userHotTake).not.toBeNull(); + expect(userHotTake?.vote).toBe(UserVote.Up); + }); + + it('should remove upvote when voting with 0', async () => { + loggedUser = '2'; + const hotTake = await con.getRepository(HotTake).save({ + userId: '1', + emoji: '🔥', + title: 'Take', + position: 0, + }); + + await con.getRepository(UserHotTake).save({ + hotTakeId: hotTake.id, + userId: '2', + vote: UserVote.Up, + }); + + const res = await client.mutate(MUTATION, { + variables: { + id: hotTake.id, + entity: 'hot_take', + vote: 0, + }, + }); + + expect(res.errors).toBeUndefined(); + + const userHotTake = await con.getRepository(UserHotTake).findOneBy({ + hotTakeId: hotTake.id, + userId: '2', + }); + expect(userHotTake?.vote).toBe(UserVote.None); + }); + + it('should allow downvoting hot takes', async () => { + loggedUser = '2'; + const hotTake = await con.getRepository(HotTake).save({ + userId: '1', + emoji: '🔥', + title: 'Take', + position: 0, + }); + + const res = await client.mutate(MUTATION, { + variables: { + id: hotTake.id, + entity: 'hot_take', + vote: -1, + }, + }); + + expect(res.errors).toBeUndefined(); + + const userHotTake = await con.getRepository(UserHotTake).findOneBy({ + hotTakeId: hotTake.id, + userId: '2', + }); + expect(userHotTake?.vote).toBe(UserVote.Down); + }); + + it('should return error for non-existent hot take', async () => { + loggedUser = '2'; + const res = await client.mutate(MUTATION, { + variables: { + id: '00000000-0000-0000-0000-000000000000', + entity: 'hot_take', + vote: 1, + }, + }); + + expect(res.errors).toBeDefined(); + }); + + it('should allow upvoting same hot take only once', async () => { + loggedUser = '2'; + const hotTake = await con.getRepository(HotTake).save({ + userId: '1', + emoji: '🔥', + title: 'Take', + position: 0, + }); + + await client.mutate(MUTATION, { + variables: { + id: hotTake.id, + entity: 'hot_take', + vote: 1, + }, + }); + + const res = await client.mutate(MUTATION, { + variables: { + id: hotTake.id, + entity: 'hot_take', + vote: 1, + }, + }); + + expect(res.errors).toBeUndefined(); + + const upvotes = await con.getRepository(UserHotTake).findBy({ + hotTakeId: hotTake.id, + userId: '2', + }); + expect(upvotes).toHaveLength(1); + }); +}); + +describe('mutation addHotTake', () => { const MUTATION = ` - mutation AddUserHotTake($input: AddUserHotTakeInput!) { - addUserHotTake(input: $input) { + mutation AddHotTake($input: AddHotTakeInput!) { + addHotTake(input: $input) { id emoji title @@ -111,7 +344,7 @@ describe('mutation addUserHotTake', () => { }); expect(res.errors).toBeUndefined(); - expect(res.data.addUserHotTake).toMatchObject({ + expect(res.data.addHotTake).toMatchObject({ emoji: '🔥', title: 'Hot take', subtitle: null, @@ -126,7 +359,7 @@ describe('mutation addUserHotTake', () => { }, }); - expect(res.data.addUserHotTake).toMatchObject({ + expect(res.data.addHotTake).toMatchObject({ emoji: '💡', title: 'Idea', subtitle: 'Explanation', @@ -135,7 +368,7 @@ describe('mutation addUserHotTake', () => { it('should enforce maximum of 5 hot takes', async () => { loggedUser = '1'; - await con.getRepository(UserHotTake).save([ + await con.getRepository(HotTake).save([ { userId: '1', emoji: '1️⃣', title: 'Take 1', position: 0 }, { userId: '1', emoji: '2️⃣', title: 'Take 2', position: 1 }, { userId: '1', emoji: '3️⃣', title: 'Take 3', position: 2 }, @@ -151,10 +384,10 @@ describe('mutation addUserHotTake', () => { }); }); -describe('mutation updateUserHotTake', () => { +describe('mutation updateHotTake', () => { const MUTATION = ` - mutation UpdateUserHotTake($id: ID!, $input: UpdateUserHotTakeInput!) { - updateUserHotTake(id: $id, input: $input) { + mutation UpdateHotTake($id: ID!, $input: UpdateHotTakeInput!) { + updateHotTake(id: $id, input: $input) { id emoji title @@ -175,7 +408,7 @@ describe('mutation updateUserHotTake', () => { it('should update hot take', async () => { loggedUser = '1'; - const hotTake = await con.getRepository(UserHotTake).save({ + const hotTake = await con.getRepository(HotTake).save({ userId: '1', emoji: '🔥', title: 'Original', @@ -190,7 +423,7 @@ describe('mutation updateUserHotTake', () => { }); expect(res.errors).toBeUndefined(); - expect(res.data.updateUserHotTake).toMatchObject({ + expect(res.data.updateHotTake).toMatchObject({ emoji: '💯', title: 'Updated', }); @@ -198,7 +431,7 @@ describe('mutation updateUserHotTake', () => { it('should update subtitle', async () => { loggedUser = '1'; - const hotTake = await con.getRepository(UserHotTake).save({ + const hotTake = await con.getRepository(HotTake).save({ userId: '1', emoji: '🔥', title: 'Hot take', @@ -212,7 +445,7 @@ describe('mutation updateUserHotTake', () => { }, }); - expect(res.data.updateUserHotTake.subtitle).toBe('New context'); + expect(res.data.updateHotTake.subtitle).toBe('New context'); }); it('should return error for non-existent item', async () => { @@ -228,7 +461,7 @@ describe('mutation updateUserHotTake', () => { it('should not allow updating other user hot take', async () => { loggedUser = '1'; - const hotTake = await con.getRepository(UserHotTake).save({ + const hotTake = await con.getRepository(HotTake).save({ userId: '2', emoji: '🔥', title: 'Other user take', @@ -245,10 +478,10 @@ describe('mutation updateUserHotTake', () => { }); }); -describe('mutation deleteUserHotTake', () => { +describe('mutation deleteHotTake', () => { const MUTATION = ` - mutation DeleteUserHotTake($id: ID!) { - deleteUserHotTake(id: $id) { + mutation DeleteHotTake($id: ID!) { + deleteHotTake(id: $id) { _ } } @@ -263,7 +496,7 @@ describe('mutation deleteUserHotTake', () => { it('should delete hot take', async () => { loggedUser = '1'; - const hotTake = await con.getRepository(UserHotTake).save({ + const hotTake = await con.getRepository(HotTake).save({ userId: '1', emoji: '🔥', title: 'To delete', @@ -273,14 +506,14 @@ describe('mutation deleteUserHotTake', () => { await client.mutate(MUTATION, { variables: { id: hotTake.id } }); const deleted = await con - .getRepository(UserHotTake) + .getRepository(HotTake) .findOneBy({ id: hotTake.id }); expect(deleted).toBeNull(); }); it('should not delete other user hot take', async () => { loggedUser = '1'; - const hotTake = await con.getRepository(UserHotTake).save({ + const hotTake = await con.getRepository(HotTake).save({ userId: '2', emoji: '🔥', title: 'Other user take', @@ -290,16 +523,16 @@ describe('mutation deleteUserHotTake', () => { await client.mutate(MUTATION, { variables: { id: hotTake.id } }); const notDeleted = await con - .getRepository(UserHotTake) + .getRepository(HotTake) .findOneBy({ id: hotTake.id }); expect(notDeleted).not.toBeNull(); }); }); -describe('mutation reorderUserHotTakes', () => { +describe('mutation reorderHotTakes', () => { const MUTATION = ` - mutation ReorderUserHotTakes($items: [ReorderUserHotTakeInput!]!) { - reorderUserHotTakes(items: $items) { + mutation ReorderHotTakes($items: [ReorderHotTakeInput!]!) { + reorderHotTakes(items: $items) { id position } @@ -315,7 +548,7 @@ describe('mutation reorderUserHotTakes', () => { it('should update positions', async () => { loggedUser = '1'; - const [item1, item2] = await con.getRepository(UserHotTake).save([ + const [item1, item2] = await con.getRepository(HotTake).save([ { userId: '1', emoji: '1️⃣', title: 'Take 1', position: 0 }, { userId: '1', emoji: '2️⃣', title: 'Take 2', position: 1 }, ]); @@ -329,7 +562,7 @@ describe('mutation reorderUserHotTakes', () => { }, }); - const reordered = res.data.reorderUserHotTakes; + const reordered = res.data.reorderHotTakes; expect( reordered.find((i: { id: string }) => i.id === item1.id).position, ).toBe(1); @@ -340,7 +573,7 @@ describe('mutation reorderUserHotTakes', () => { it('should not reorder other user hot takes', async () => { loggedUser = '1'; - const otherUserItem = await con.getRepository(UserHotTake).save({ + const otherUserItem = await con.getRepository(HotTake).save({ userId: '2', emoji: '🔥', title: 'Other user', @@ -354,7 +587,7 @@ describe('mutation reorderUserHotTakes', () => { }); const notUpdated = await con - .getRepository(UserHotTake) + .getRepository(HotTake) .findOneBy({ id: otherUserItem.id }); expect(notUpdated?.position).toBe(0); }); diff --git a/src/common/vote.ts b/src/common/vote.ts index 706057749f..81173df746 100644 --- a/src/common/vote.ts +++ b/src/common/vote.ts @@ -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 = { @@ -154,3 +156,35 @@ export const voteComment = async ({ return { _: true }; }; + +export const voteHotTake = async ({ + ctx, + id, + vote, +}: UserVoteProps): Promise => { + 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 }; +}; diff --git a/src/entity/user/HotTake.ts b/src/entity/user/HotTake.ts new file mode 100644 index 0000000000..9fd130dac3 --- /dev/null +++ b/src/entity/user/HotTake.ts @@ -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; +} diff --git a/src/entity/user/UserHotTake.ts b/src/entity/user/UserHotTake.ts index 872c33d515..ea6d63ad52 100644 --- a/src/entity/user/UserHotTake.ts +++ b/src/entity/user/UserHotTake.ts @@ -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; @ManyToOne('User', { lazy: true, diff --git a/src/entity/user/index.ts b/src/entity/user/index.ts index 99c0b8001b..2949ebe2d6 100644 --- a/src/entity/user/index.ts +++ b/src/entity/user/index.ts @@ -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'; diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index 0f3538d1ab..6aff122c4e 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -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 = (value: T, ctx: Context): T | null => ctx.userId ? value : null; @@ -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: { diff --git a/src/migration/1769156534090-AddUserHotTakeUpvotes.ts b/src/migration/1769156534090-AddUserHotTakeUpvotes.ts new file mode 100644 index 0000000000..470349fe7f --- /dev/null +++ b/src/migration/1769156534090-AddUserHotTakeUpvotes.ts @@ -0,0 +1,149 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class HotTakeVoting1769156534090 implements MigrationInterface { + name = 'HotTakeVoting1769156534090'; + + public async up(queryRunner: QueryRunner): Promise { + // 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 { + // 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"'); + } +} diff --git a/src/schema/userHotTake.ts b/src/schema/userHotTake.ts index d33cbff617..505b369e0c 100644 --- a/src/schema/userHotTake.ts +++ b/src/schema/userHotTake.ts @@ -3,7 +3,7 @@ import { traceResolvers } from './trace'; import { AuthContext, BaseContext, Context } from '../Context'; import graphorm from '../graphorm'; import { offsetPageGenerator, GQLEmptyResponse } from './common'; -import { UserHotTake } from '../entity/user/UserHotTake'; +import { HotTake } from '../entity/user/HotTake'; import { ValidationError } from 'apollo-server-errors'; import { addUserHotTakeSchema, @@ -15,7 +15,7 @@ import { } from '../common/schema/userHotTake'; import { NEW_ITEM_POSITION } from '../common/constants'; -interface GQLUserHotTake { +interface GQLHotTake { id: string; userId: string; emoji: string; @@ -28,38 +28,40 @@ interface GQLUserHotTake { const MAX_HOT_TAKES = 5; export const typeDefs = /* GraphQL */ ` - type UserHotTake { + type HotTake { id: ID! emoji: String! title: String! subtitle: String position: Int! + upvotes: Int! + upvoted: Boolean createdAt: DateTime! } - type UserHotTakeEdge { - node: UserHotTake! + type HotTakeEdge { + node: HotTake! cursor: String! } - type UserHotTakeConnection { + type HotTakeConnection { pageInfo: PageInfo! - edges: [UserHotTakeEdge!]! + edges: [HotTakeEdge!]! } - input AddUserHotTakeInput { + input AddHotTakeInput { emoji: String! title: String! subtitle: String } - input UpdateUserHotTakeInput { + input UpdateHotTakeInput { emoji: String title: String subtitle: String } - input ReorderUserHotTakeInput { + input ReorderHotTakeInput { id: ID! position: Int! } @@ -68,31 +70,29 @@ export const typeDefs = /* GraphQL */ ` """ Get a user's hot takes """ - userHotTakes(userId: ID!, first: Int, after: String): UserHotTakeConnection! + hotTakes(userId: ID!, first: Int, after: String): HotTakeConnection! } extend type Mutation { """ Add a hot take to the user's profile (max 5) """ - addUserHotTake(input: AddUserHotTakeInput!): UserHotTake! @auth + addHotTake(input: AddHotTakeInput!): HotTake! @auth """ - Update a user's hot take + Update a hot take """ - updateUserHotTake(id: ID!, input: UpdateUserHotTakeInput!): UserHotTake! - @auth + updateHotTake(id: ID!, input: UpdateHotTakeInput!): HotTake! @auth """ - Delete a user's hot take + Delete a hot take """ - deleteUserHotTake(id: ID!): EmptyResponse! @auth + deleteHotTake(id: ID!): EmptyResponse! @auth """ - Reorder user's hot takes + Reorder hot takes """ - reorderUserHotTakes(items: [ReorderUserHotTakeInput!]!): [UserHotTake!]! - @auth + reorderHotTakes(items: [ReorderHotTakeInput!]!): [HotTake!]! @auth } `; @@ -101,19 +101,19 @@ export const resolvers: IResolvers = traceResolvers< BaseContext >({ Query: { - userHotTakes: async ( + hotTakes: async ( _, args: { userId: string; first?: number; after?: string }, ctx: Context, info, ) => { - const pageGenerator = offsetPageGenerator(50, 100); + const pageGenerator = offsetPageGenerator(50, 100); const page = pageGenerator.connArgsToPage({ first: args.first, after: args.after, }); - return graphorm.queryPaginated( + return graphorm.queryPaginated( ctx, info, (nodeSize) => pageGenerator.hasPreviousPage(page, nodeSize), @@ -138,7 +138,7 @@ export const resolvers: IResolvers = traceResolvers< }, Mutation: { - addUserHotTake: async ( + addHotTake: async ( _, args: { input: AddUserHotTakeInput }, ctx: AuthContext, @@ -146,7 +146,7 @@ export const resolvers: IResolvers = traceResolvers< ) => { const input = addUserHotTakeSchema.parse(args.input); - const count = await ctx.con.getRepository(UserHotTake).count({ + const count = await ctx.con.getRepository(HotTake).count({ where: { userId: ctx.userId }, }); @@ -156,7 +156,7 @@ export const resolvers: IResolvers = traceResolvers< ); } - const hotTake = ctx.con.getRepository(UserHotTake).create({ + const hotTake = ctx.con.getRepository(HotTake).create({ userId: ctx.userId, emoji: input.emoji, title: input.title, @@ -164,7 +164,7 @@ export const resolvers: IResolvers = traceResolvers< position: NEW_ITEM_POSITION, }); - await ctx.con.getRepository(UserHotTake).save(hotTake); + await ctx.con.getRepository(HotTake).save(hotTake); return graphorm.queryOneOrFail(ctx, info, (builder) => { builder.queryBuilder.where(`"${builder.alias}"."id" = :id`, { @@ -174,7 +174,7 @@ export const resolvers: IResolvers = traceResolvers< }); }, - updateUserHotTake: async ( + updateHotTake: async ( _, args: { id: string; input: UpdateUserHotTakeInput }, ctx: AuthContext, @@ -182,7 +182,7 @@ export const resolvers: IResolvers = traceResolvers< ) => { const input = updateUserHotTakeSchema.parse(args.input); - const hotTake = await ctx.con.getRepository(UserHotTake).findOne({ + const hotTake = await ctx.con.getRepository(HotTake).findOne({ where: { id: args.id, userId: ctx.userId }, }); @@ -190,7 +190,7 @@ export const resolvers: IResolvers = traceResolvers< throw new ValidationError('Hot take not found'); } - const updateData: Partial = {}; + const updateData: Partial = {}; if (input.emoji !== undefined) { updateData.emoji = input.emoji; } @@ -203,7 +203,7 @@ export const resolvers: IResolvers = traceResolvers< if (Object.keys(updateData).length > 0) { await ctx.con - .getRepository(UserHotTake) + .getRepository(HotTake) .update({ id: args.id }, updateData); } @@ -215,19 +215,19 @@ export const resolvers: IResolvers = traceResolvers< }); }, - deleteUserHotTake: async ( + deleteHotTake: async ( _, args: { id: string }, ctx: AuthContext, ): Promise => { await ctx.con - .getRepository(UserHotTake) + .getRepository(HotTake) .delete({ id: args.id, userId: ctx.userId }); return { _: true }; }, - reorderUserHotTakes: async ( + reorderHotTakes: async ( _, args: { items: ReorderUserHotTakeInput[] }, ctx: AuthContext, @@ -241,7 +241,7 @@ export const resolvers: IResolvers = traceResolvers< .join(' '); await ctx.con - .getRepository(UserHotTake) + .getRepository(HotTake) .createQueryBuilder() .update() .set({ position: () => `CASE ${whenClauses} ELSE position END` }) diff --git a/src/schema/users.ts b/src/schema/users.ts index 54f558f516..3b44658ab6 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -87,6 +87,7 @@ import { validateWorkEmailDomain, voteComment, votePost, + voteHotTake, } from '../common'; import { getSearchQuery, GQLEmptyResponse, processSearchQuery } from './common'; import { ActiveView } from '../entity/ActiveView'; @@ -3321,6 +3322,8 @@ export const resolvers: IResolvers = traceResolvers< return votePost({ ctx, id, vote }); case UserVoteEntity.Comment: return voteComment({ ctx, id, vote }); + case UserVoteEntity.HotTake: + return voteHotTake({ ctx, id, vote }); default: throw new ValidationError('Unsupported vote entity'); } diff --git a/src/types.ts b/src/types.ts index a5dfe2886c..1c4f6d698f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -169,6 +169,7 @@ export enum UserVote { export enum UserVoteEntity { Comment = 'comment', Post = 'post', + HotTake = 'hot_take', } export const maxFeedsPerUser = 20;