Files
Tom Moor 281b778b2d fix: Suspended users should not be included in cached member count (#12197)
* fix: Suspended users should not be included in cached member count for groups

* fix: Defer CounterCache hook registration until model is initialized

The previous test-only no-op hid a timing bug where setImmediate could
fire before the Sequelize instance had registered the related model,
causing "Model not initialized" failures. Poll until the model is
ready, and unref the pending immediate so it does not keep the event
loop alive in environments where the database is never initialized.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* perf: Reduce overhead of group member count invalidation

Select only the groupId column with raw queries and de-duplicate before
issuing Redis deletes, avoiding loading full GroupUser rows into memory
when a user belongs to many groups.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: unref Redis healthcheck interval

Don't keep the Node event loop alive solely for the periodic ping; the
event loop should drain on its own when the application is shutting
down or a Jest worker is finishing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor: Centralize counter cache key in RedisPrefixHelper

Avoid duplicating the "count:<Model>:<relation>:<id>" string between
the CounterCache decorator and the User suspension hook by routing
both through a single getCounterCacheKey helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: Walk to parent transaction when scheduling cache invalidation

Nested savepoints commit independently of their outer transaction, so
afterCommit callbacks attached to the inner transaction may run after
the outer rolls back, or never run at all. Match the pattern used in
Collection, Event, and base/Model and walk to the parent transaction
so the cache invalidation fires after the real outer commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 11:24:44 -04:00

141 lines
3.0 KiB
TypeScript

import type { InferAttributes, InferCreationAttributes } from "sequelize";
import { Op } from "sequelize";
import {
BelongsTo,
Column,
ForeignKey,
Table,
HasMany,
BelongsToMany,
DataType,
Scopes,
} from "sequelize-typescript";
import { GroupValidation } from "@shared/validations";
import ExternalGroup from "./ExternalGroup";
import GroupMembership from "./GroupMembership";
import GroupUser from "./GroupUser";
import Team from "./Team";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import { CounterCache } from "./decorators/CounterCache";
import Fix from "./decorators/Fix";
import Length from "./validators/Length";
import NotContainsUrl from "./validators/NotContainsUrl";
@Scopes(() => ({
withMembership: (userId: string) => ({
include: [
{
association: "groupUsers",
required: true,
where: {
userId,
},
},
],
}),
}))
@Table({
tableName: "groups",
modelName: "group",
validate: {
async isUniqueNameInTeam() {
const foundItem = await Group.findOne({
where: {
teamId: this.teamId,
name: {
[Op.iLike]: this.name,
},
id: {
[Op.not]: this.id,
},
},
});
if (foundItem) {
throw new Error("The name of this group is already in use");
}
},
},
})
@Fix
class Group extends ParanoidModel<
InferAttributes<Group>,
Partial<InferCreationAttributes<Group>>
> {
@Length({
min: 0,
max: GroupValidation.maxNameLength,
msg: `name must be ${GroupValidation.maxNameLength} characters or less`,
})
@NotContainsUrl
@Column
name: string;
@Length({
min: 0,
max: GroupValidation.maxDescriptionLength,
msg: `description must be ${GroupValidation.maxDescriptionLength} characters or less`,
})
@Column(DataType.TEXT)
description: string;
@Column
externalId: string;
@Column(DataType.BOOLEAN)
disableMentions: boolean;
static filterByMember(userId: string | undefined) {
return userId
? this.scope({ method: ["withMembership", userId] })
: this.scope("defaultScope");
}
// associations
@HasMany(() => GroupUser, "groupId")
groupUsers: GroupUser[];
@HasMany(() => ExternalGroup, "groupId")
externalGroups: ExternalGroup[];
@HasMany(() => GroupMembership, "groupId")
groupMemberships: GroupMembership[];
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
@BelongsTo(() => User, "createdById")
createdBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
createdById: string;
@BelongsToMany(() => User, () => GroupUser)
users: User[];
@CounterCache(() => GroupUser, {
as: "members",
foreignKey: "groupId",
include: [
{
association: "user",
required: true,
attributes: [],
where: {
suspendedAt: { [Op.is]: null },
},
},
],
})
memberCount: Promise<number>;
}
export default Group;