From 9bd4e90abdb1fa236016c72eef65e1872f6fee91 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:24:00 +0300 Subject: [PATCH] User leaving a group now clears their suggestions, invitations they sent & invitations the group received --- app/db/seed/factories/SQGroupLikeFactory.ts | 13 +++ .../factories/SQGroupSuggestionFactory.ts | 13 +++ .../sendouq/SQGroupRepository.server.test.ts | 101 ++++++++++++++++++ .../sendouq/SQGroupRepository.server.ts | 32 +++++- ...6-09-06-sendouq-leave-clears-challenges.md | 5 + 5 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 app/db/seed/factories/SQGroupLikeFactory.ts create mode 100644 app/db/seed/factories/SQGroupSuggestionFactory.ts create mode 100644 changelog/2026-09-06-sendouq-leave-clears-challenges.md diff --git a/app/db/seed/factories/SQGroupLikeFactory.ts b/app/db/seed/factories/SQGroupLikeFactory.ts new file mode 100644 index 000000000..d5cecfb88 --- /dev/null +++ b/app/db/seed/factories/SQGroupLikeFactory.ts @@ -0,0 +1,13 @@ +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import { defineFactory } from "../core/defineFactory"; + +type InsertArgs = Parameters[0]; + +/** One group challenging another, sent by a member of the liker group. */ +export const { create } = defineFactory({ + insert: async (args: InsertArgs) => { + await SQGroupRepository.insertLike(args); + + return args; + }, +}); diff --git a/app/db/seed/factories/SQGroupSuggestionFactory.ts b/app/db/seed/factories/SQGroupSuggestionFactory.ts new file mode 100644 index 000000000..2c2c43ef6 --- /dev/null +++ b/app/db/seed/factories/SQGroupSuggestionFactory.ts @@ -0,0 +1,13 @@ +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import { defineFactory } from "../core/defineFactory"; + +type InsertArgs = Parameters[0]; + +/** A group flagged as worth a look by one of the suggester group's own members. */ +export const { create } = defineFactory({ + insert: async (args: InsertArgs) => { + await SQGroupRepository.insertSuggestion(args); + + return args; + }, +}); diff --git a/app/features/sendouq/SQGroupRepository.server.test.ts b/app/features/sendouq/SQGroupRepository.server.test.ts index b260acfc1..1ba26cf0c 100644 --- a/app/features/sendouq/SQGroupRepository.server.test.ts +++ b/app/features/sendouq/SQGroupRepository.server.test.ts @@ -3,11 +3,14 @@ import { describe, expect, test } from "vitest"; import { backdate } from "~/db/seed/core/backdate"; import * as GroupMatchContinueVoteFactory from "~/db/seed/factories/GroupMatchContinueVoteFactory"; import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; +import * as SQGroupLikeFactory from "~/db/seed/factories/SQGroupLikeFactory"; +import * as SQGroupSuggestionFactory from "~/db/seed/factories/SQGroupSuggestionFactory"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as TeamFactory from "~/db/seed/factories/TeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { db } from "~/db/sql"; import * as GroupMatchContinueVoteRepository from "~/features/sendouq-match/GroupMatchContinueVoteRepository.server"; +import invariant from "~/utils/invariant"; import { FULL_GROUP_SIZE } from "./q-constants"; import * as SQGroupRepository from "./SQGroupRepository.server"; @@ -392,4 +395,102 @@ describe("leaveGroup", () => { expect(groupRow).toBeUndefined(); expect(await allChatRooms()).toHaveLength(0); }); + + test("clears the challenges the group received", async () => { + const { ownGroup, challengerGroup } = await setupGroupWithChallenges(); + + await SQGroupRepository.leaveGroup(ownGroup.leaverId); + + const likes = await SQGroupRepository.findAllLikesByGroupId(ownGroup.id); + expect(likes.received).toHaveLength(0); + expect( + await SQGroupRepository.findAllLikesByGroupId(challengerGroup.id), + ).toMatchObject({ given: [] }); + }); + + test("clears the challenges the leaver sent but not those of the members who stay", async () => { + const { ownGroup, leaverTargetGroup, stayerTargetGroup } = + await setupGroupWithChallenges(); + + await SQGroupRepository.leaveGroup(ownGroup.leaverId); + + const likes = await SQGroupRepository.findAllLikesByGroupId(ownGroup.id); + expect(likes.given.map((like) => like.groupId)).toEqual([ + stayerTargetGroup.id, + ]); + expect( + await SQGroupRepository.findAllLikesByGroupId(leaverTargetGroup.id), + ).toMatchObject({ received: [] }); + }); + + test("clears the suggestions the leaver made but not those of the members who stay", async () => { + const { ownGroup, stayerTargetGroup } = await setupGroupWithChallenges(); + + await SQGroupRepository.leaveGroup(ownGroup.leaverId); + + const suggestions = await SQGroupRepository.findAllSuggestionsByGroupId( + ownGroup.id, + ); + expect(suggestions.map((suggestion) => suggestion.groupId)).toEqual([ + stayerTargetGroup.id, + ]); + }); }); + +/** + * A group of two whose first member is about to leave, with a challenge received, one sent by + * each of its members and a suggestion made by each of them. + */ +const setupGroupWithChallenges = async () => { + const [leaver, stayer] = await UserFactory.createMany(2); + invariant(leaver && stayer, "Expected two users"); + + const ownGroup = await SQGroupFactory.create({ + memberUserIds: [leaver.id, stayer.id], + }); + + const [challengerGroup, leaverTargetGroup, stayerTargetGroup] = + await createSoloGroups(3); + + await SQGroupLikeFactory.create({ + likerGroupId: challengerGroup.id, + targetGroupId: ownGroup.id, + createdByUserId: challengerGroup.memberUserIds[0]!, + }); + await SQGroupLikeFactory.create({ + likerGroupId: ownGroup.id, + targetGroupId: leaverTargetGroup.id, + createdByUserId: leaver.id, + }); + await SQGroupLikeFactory.create({ + likerGroupId: ownGroup.id, + targetGroupId: stayerTargetGroup.id, + createdByUserId: stayer.id, + }); + + await SQGroupSuggestionFactory.create({ + suggesterGroupId: ownGroup.id, + targetGroupId: challengerGroup.id, + createdByUserId: leaver.id, + }); + await SQGroupSuggestionFactory.create({ + suggesterGroupId: ownGroup.id, + targetGroupId: stayerTargetGroup.id, + createdByUserId: stayer.id, + }); + + return { + ownGroup: { ...ownGroup, leaverId: leaver.id }, + challengerGroup, + leaverTargetGroup, + stayerTargetGroup, + }; +}; + +const createSoloGroups = async (count: number) => { + const users = await UserFactory.createMany(count); + + return Promise.all( + users.map((user) => SQGroupFactory.create({ memberUserIds: [user.id] })), + ); +}; diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts index 052bead6d..dcb974825 100644 --- a/app/features/sendouq/SQGroupRepository.server.ts +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -325,6 +325,31 @@ export async function deleteLikesAndSuggestionsByGroupId( await deleteSuggestionsByGroupId(groupId, trx); } +/** Clears what the departing member is responsible for: every challenge the group received (the roster the other group challenged is gone) plus the challenges and suggestions that member made themselves. */ +async function deleteLikesAndSuggestionsOnLeave( + { groupId, userId }: { groupId: number; userId: number }, + trx: Transaction, +) { + await trx + .deleteFrom("GroupLike") + .where((eb) => + eb.or([ + eb("GroupLike.targetGroupId", "=", groupId), + eb.and([ + eb("GroupLike.likerGroupId", "=", groupId), + eb("GroupLike.createdByUserId", "=", userId), + ]), + ]), + ) + .execute(); + + await trx + .deleteFrom("GroupSuggestion") + .where("GroupSuggestion.suggesterGroupId", "=", groupId) + .where("GroupSuggestion.createdByUserId", "=", userId) + .execute(); +} + export function morphGroups({ survivingGroupId, otherGroupId, @@ -836,7 +861,7 @@ export function deleteAllLikesByGroupId(groupId: number) { return db.transaction().execute((trx) => deleteLikesByGroupId(groupId, trx)); } -/** Removes the user from their group (deleting it if they were last). A ready check the group was in is called off; returns the ids of the groups that were in it. */ +/** Removes the user from their group (deleting it if they were last). A ready check the group was in is called off; returns the ids of the groups that were in it. Challenges the group received and challenges/suggestions the leaver made are cleared. */ export function leaveGroup(userId: number) { return db.transaction().execute(async (trx) => { const userGroup = await trx @@ -908,6 +933,11 @@ export function leaveGroup(userId: number) { throw new SendouQError("Can't leave group when already in a match"); } + await deleteLikesAndSuggestionsOnLeave( + { groupId: userGroup.id, userId }, + trx, + ); + await syncTeamId(userGroup.id, trx); return { abortedReadyCheckGroupIds }; diff --git a/changelog/2026-09-06-sendouq-leave-clears-challenges.md b/changelog/2026-09-06-sendouq-leave-clears-challenges.md new file mode 100644 index 000000000..57b5dc9a4 --- /dev/null +++ b/changelog/2026-09-06-sendouq-leave-clears-challenges.md @@ -0,0 +1,5 @@ +--- +navItem: sendouq +type: feature +--- +User leaving a group now clears their suggestions, invitations they sent & invitations the group received