diff --git a/__tests__/boot.ts b/__tests__/boot.ts index 64ded3c0cd..d783c4589f 100644 --- a/__tests__/boot.ts +++ b/__tests__/boot.ts @@ -1573,6 +1573,7 @@ describe('boot misc', () => { permalink: 'http://localhost:5002/squads/s1', public: true, type: SourceType.Squad, + favoritedAt: null, currentMember: { permissions: [SourcePermissions.Post], }, @@ -1587,6 +1588,7 @@ describe('boot misc', () => { permalink: 'http://localhost:5002/squads/s2', public: false, type: SourceType.Squad, + favoritedAt: null, currentMember: { permissions: [SourcePermissions.Post], }, @@ -1601,6 +1603,7 @@ describe('boot misc', () => { permalink: 'http://localhost:5002/squads/s5', public: false, type: SourceType.Squad, + favoritedAt: null, currentMember: { permissions: [], }, @@ -1663,6 +1666,7 @@ describe('boot misc', () => { permalink: 'http://localhost:5002/squads/s1', public: true, type: SourceType.Squad, + favoritedAt: null, currentMember: { permissions: [SourcePermissions.Post], }, diff --git a/__tests__/sources.ts b/__tests__/sources.ts index 6a75f035be..34c3df6ea9 100644 --- a/__tests__/sources.ts +++ b/__tests__/sources.ts @@ -5214,6 +5214,93 @@ describe('mutation expandPinnedPosts', () => { }); }); +describe('mutation toggleFavoriteSource', () => { + const MUTATION = ` + mutation ToggleFavoriteSource($sourceId: ID!) { + toggleFavoriteSource(sourceId: $sourceId) { + _ + } + }`; + + const variables = { sourceId: 's1' }; + + beforeEach(async () => { + await con.getRepository(SquadSource).save({ + id: 's1', + handle: 's1', + name: 'Squad', + private: true, + }); + await con.getRepository(SourceMember).save({ + sourceId: 's1', + userId: '1', + referralToken: 'rt-fav', + role: SourceMemberRoles.Member, + }); + }); + + it('should not authorize when not logged in', () => + testMutationErrorCode( + client, + { mutation: MUTATION, variables }, + 'UNAUTHENTICATED', + )); + + it('should throw when user is blocked', async () => { + loggedUser = '1'; + await con + .getRepository(SourceMember) + .update( + { sourceId: 's1', userId: '1' }, + { role: SourceMemberRoles.Blocked }, + ); + await testMutationErrorCode( + client, + { mutation: MUTATION, variables }, + 'FORBIDDEN', + ); + }); + + it('should set favoritedAt when unset and clear when set', async () => { + loggedUser = '1'; + + let sourceMember = await con + .getRepository(SourceMember) + .findOneBy({ sourceId: 's1', userId: '1' }); + expect(sourceMember?.favoritedAt).toBeNull(); + + await client.mutate(MUTATION, { variables }); + sourceMember = await con + .getRepository(SourceMember) + .findOneBy({ sourceId: 's1', userId: '1' }); + expect(sourceMember?.favoritedAt).toBeInstanceOf(Date); + + await client.mutate(MUTATION, { variables }); + sourceMember = await con + .getRepository(SourceMember) + .findOneBy({ sourceId: 's1', userId: '1' }); + expect(sourceMember?.favoritedAt).toBeNull(); + }); + + it('should expose favoritedAt on mySourceMemberships', async () => { + loggedUser = '1'; + await client.mutate(MUTATION, { variables }); + + const res = await client.query( + `{ + mySourceMemberships { + edges { node { source { id } favoritedAt } } + } + }`, + ); + expect(res.errors).toBeFalsy(); + const edge = res.data.mySourceMemberships.edges.find( + (e: { node: { source: { id: string } } }) => e.node.source.id === 's1', + ); + expect(edge?.node.favoritedAt).toBeTruthy(); + }); +}); + describe('SourceMember flags field', () => { const QUERY = `{ source(id: "a") { diff --git a/src/entity/SourceMember.ts b/src/entity/SourceMember.ts index 2cc26b1a8d..bd242d1fb6 100644 --- a/src/entity/SourceMember.ts +++ b/src/entity/SourceMember.ts @@ -40,6 +40,9 @@ export class SourceMember { @Column({ default: () => 'now()' }) createdAt: Date; + @Column({ type: 'timestamptz', nullable: true }) + favoritedAt: Date | null; + @Column({ type: 'text' }) role: SourceMemberRoles; diff --git a/src/migration/1779395956183-AddFavoritedAtToSourceMember.ts b/src/migration/1779395956183-AddFavoritedAtToSourceMember.ts new file mode 100644 index 0000000000..8cd5ea2122 --- /dev/null +++ b/src/migration/1779395956183-AddFavoritedAtToSourceMember.ts @@ -0,0 +1,21 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFavoritedAtToSourceMember1779395956183 + implements MigrationInterface +{ + name = 'AddFavoritedAtToSourceMember1779395956183'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + ALTER TABLE "source_member" + ADD COLUMN IF NOT EXISTS "favoritedAt" TIMESTAMP WITH TIME ZONE + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + ALTER TABLE "source_member" + DROP COLUMN IF EXISTS "favoritedAt" + `); + } +} diff --git a/src/routes/boot.ts b/src/routes/boot.ts index cab4d4174d..0c07c5f788 100644 --- a/src/routes/boot.ts +++ b/src/routes/boot.ts @@ -110,6 +110,7 @@ import { LiveRoomStatus } from '../common/schema/liveRooms'; export type BootSquadSource = Omit & { permalink: string; + favoritedAt: Date | null; currentMember: { permissions: SourcePermissions[]; }; @@ -402,6 +403,7 @@ const getSquads = async ( .addSelect('role') .addSelect('"moderationRequired"') .addSelect('"memberPostingRank"') + .addSelect('sm."favoritedAt"', 'favoritedAt') .from(SourceMember, 'sm') .innerJoin( SquadSource, @@ -410,13 +412,20 @@ const getSquads = async ( ) .where('sm."userId" = :userId', { userId }) .andWhere('sm."role" != :role', { role: SourceMemberRoles.Blocked }) - .orderBy('LOWER(s.name)', 'ASC') + .orderBy('sm."favoritedAt" IS NULL', 'ASC') + .addOrderBy('sm."favoritedAt"', 'DESC', 'NULLS LAST') + .addOrderBy('LOWER(s.name)', 'ASC') .getRawMany< - GQLSource & { role: SourceMemberRoles; memberPostingRank: number } + GQLSource & { + role: SourceMemberRoles; + memberPostingRank: number; + favoritedAt: Date | null; + } >(); return sources.map((source) => { - const { role, memberPostingRank, image, ...restSource } = source; + const { role, memberPostingRank, image, favoritedAt, ...restSource } = + source; const permissions = getPermissionsForMember( { role }, @@ -432,6 +441,7 @@ const getSquads = async ( ...restSource, image: mapCloudinaryUrl(image), permalink: getSourceLink(source), + favoritedAt, currentMember: { permissions: essentialPermissions, }, diff --git a/src/schema/sources.ts b/src/schema/sources.ts index 93df5429ab..626db9ec6c 100644 --- a/src/schema/sources.ts +++ b/src/schema/sources.ts @@ -388,6 +388,11 @@ export const typeDefs = /* GraphQL */ ` All the flags for source member """ flags: SourceMemberFlagsPublic + + """ + When the viewer favorited this membership (null if not favorited) + """ + favoritedAt: DateTime } type SourceMemberConnection { @@ -988,6 +993,17 @@ export const typeDefs = /* GraphQL */ ` """ sourceId: ID! ): EmptyResponse! @auth + + """ + Toggle the favorited state of the viewer's membership in a squad. + Sets favoritedAt to now() when null, clears to null when set. + """ + toggleFavoriteSource( + """ + Source id to toggle favorite for + """ + sourceId: ID! + ): EmptyResponse! @auth } `; @@ -1619,6 +1635,26 @@ const updateHideFeedPostsFlag = async ( return { _: true }; }; +const toggleFavoriteSourceMembership = async ( + ctx: Context, + sourceId: string, +): Promise => { + await ensureSourcePermissions(ctx, sourceId, SourcePermissions.View); + + await ctx.con + .getRepository(SourceMember) + .createQueryBuilder() + .update() + .set({ + favoritedAt: () => + `CASE WHEN "favoritedAt" IS NULL THEN now() ELSE NULL END`, + }) + .where({ sourceId, userId: ctx.userId }) + .execute(); + + return { _: true }; +}; + const togglePinnedPosts = async ( ctx: Context, sourceId: string, @@ -3020,6 +3056,13 @@ export const resolvers: IResolvers = { ) => { return togglePinnedPosts(ctx, sourceId, false); }, + toggleFavoriteSource: async ( + _, + { sourceId }: { sourceId: string }, + ctx: AuthContext, + ) => { + return toggleFavoriteSourceMembership(ctx, sourceId); + }, }, Source: { image: (source: GQLSource): GQLSource['image'] =>