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
4 changes: 4 additions & 0 deletions __tests__/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1573,6 +1573,7 @@ describe('boot misc', () => {
permalink: 'http://localhost:5002/squads/s1',
public: true,
type: SourceType.Squad,
favoritedAt: null,
currentMember: {
permissions: [SourcePermissions.Post],
},
Expand All @@ -1587,6 +1588,7 @@ describe('boot misc', () => {
permalink: 'http://localhost:5002/squads/s2',
public: false,
type: SourceType.Squad,
favoritedAt: null,
currentMember: {
permissions: [SourcePermissions.Post],
},
Expand All @@ -1601,6 +1603,7 @@ describe('boot misc', () => {
permalink: 'http://localhost:5002/squads/s5',
public: false,
type: SourceType.Squad,
favoritedAt: null,
currentMember: {
permissions: [],
},
Expand Down Expand Up @@ -1663,6 +1666,7 @@ describe('boot misc', () => {
permalink: 'http://localhost:5002/squads/s1',
public: true,
type: SourceType.Squad,
favoritedAt: null,
currentMember: {
permissions: [SourcePermissions.Post],
},
Expand Down
87 changes: 87 additions & 0 deletions __tests__/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
3 changes: 3 additions & 0 deletions src/entity/SourceMember.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
21 changes: 21 additions & 0 deletions src/migration/1779395956183-AddFavoritedAtToSourceMember.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

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

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(/* sql */ `
ALTER TABLE "source_member"
ADD COLUMN IF NOT EXISTS "favoritedAt" TIMESTAMP WITH TIME ZONE
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(/* sql */ `
ALTER TABLE "source_member"
DROP COLUMN IF EXISTS "favoritedAt"
`);
}
}
16 changes: 13 additions & 3 deletions src/routes/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ import { LiveRoomStatus } from '../common/schema/liveRooms';

export type BootSquadSource = Omit<GQLSource, 'currentMember'> & {
permalink: string;
favoritedAt: Date | null;
currentMember: {
permissions: SourcePermissions[];
};
Expand Down Expand Up @@ -402,6 +403,7 @@ const getSquads = async (
.addSelect('role')
.addSelect('"moderationRequired"')
.addSelect('"memberPostingRank"')
.addSelect('sm."favoritedAt"', 'favoritedAt')
.from(SourceMember, 'sm')
.innerJoin(
SquadSource,
Expand All @@ -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 },
Expand All @@ -432,6 +441,7 @@ const getSquads = async (
...restSource,
image: mapCloudinaryUrl(image),
permalink: getSourceLink(source),
favoritedAt,
currentMember: {
permissions: essentialPermissions,
},
Expand Down
43 changes: 43 additions & 0 deletions src/schema/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
`;

Expand Down Expand Up @@ -1619,6 +1635,26 @@ const updateHideFeedPostsFlag = async (
return { _: true };
};

const toggleFavoriteSourceMembership = async (
ctx: Context,
sourceId: string,
): Promise<GQLEmptyResponse> => {
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,
Expand Down Expand Up @@ -3020,6 +3056,13 @@ export const resolvers: IResolvers<unknown, BaseContext> = {
) => {
return togglePinnedPosts(ctx, sourceId, false);
},
toggleFavoriteSource: async (
_,
{ sourceId }: { sourceId: string },
ctx: AuthContext,
) => {
return toggleFavoriteSourceMembership(ctx, sourceId);
},
},
Source: {
image: (source: GQLSource): GQLSource['image'] =>
Expand Down
Loading