Files
outline/server/models/Reaction.ts
T
Tom Moor 0139b91b5d chore: Replace lodash with es-toolkit (#12281)
* chore: Replace lodash with es-toolkit

Migrate all direct lodash imports to es-toolkit/compat for a smaller,
faster, lodash-compatible utility library. Transitive lodash usage from
other packages remains unchanged.

* fix: Restore isPlainObject semantics in CanCan policy

The lodash migration aliased `isObject` to `lodash/isPlainObject` and
the codemod incorrectly mapped the local name to es-toolkit's `isObject`,
which also returns true for arrays and functions. This caused condition
objects in policy definitions to be skipped, breaking authorization
checks across the codebase.

* fix: Restore unicode-aware length counting in validators

es-toolkit/compat's size() returns string.length, while lodash's _.size()
counts unicode code points. Switch to [...value].length to preserve the
previous behavior so multi-byte characters like emoji count as one.
2026-05-06 21:03:47 -04:00

163 lines
3.6 KiB
TypeScript

import { cloneDeep, uniq } from "es-toolkit/compat";
import type {
Attributes,
CreationAttributes,
FindOrCreateOptions,
InferAttributes,
InferCreationAttributes,
InstanceDestroyOptions,
} from "sequelize";
import {
AfterCreate,
AfterDestroy,
BelongsTo,
Column,
DataType,
ForeignKey,
Table,
} from "sequelize-typescript";
import { createContext } from "@server/context";
import type { APIContext } from "@server/types";
import Comment from "./Comment";
import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
import Length from "./validators/Length";
@Table({ tableName: "reactions", modelName: "reaction" })
@Fix
class Reaction extends IdModel<
InferAttributes<Reaction>,
Partial<InferCreationAttributes<Reaction>>
> {
@Length({
max: 50,
msg: `emoji must be 50 characters or less`,
})
@Column(DataType.STRING)
emoji: string;
// associations
@BelongsTo(() => User)
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Comment)
comment: Comment;
@ForeignKey(() => Comment)
@Column(DataType.UUID)
commentId: string;
@AfterCreate
public static async addReactionToCommentCache(
model: Reaction,
ctx: APIContext["context"] &
FindOrCreateOptions<Attributes<Reaction>, CreationAttributes<Reaction>>
) {
const { transaction } = ctx;
const lock = transaction
? {
level: transaction.LOCK.UPDATE,
of: Comment,
}
: undefined;
const comment = await Comment.findByPk(model.commentId, {
transaction,
lock,
});
if (!comment) {
return;
}
const reactions = cloneDeep(comment.reactions) ?? [];
const reaction = reactions.find((r) => r.emoji === model.emoji);
if (!reaction) {
reactions.push({ emoji: model.emoji, userIds: [model.userId] });
} else {
reaction.userIds = uniq([...reaction.userIds, model.userId]);
}
comment.reactions = reactions;
// Pass only the fields needed in APIContext; otherwise sequelize props will be overwritten.
const context = createContext({
user: ctx.auth.user,
authType: ctx.auth.type,
...ctx,
});
await comment.saveWithCtx(
context,
{
fields: ["reactions"],
silent: true,
},
{ name: "add_reaction", data: { emoji: model.emoji } }
);
}
@AfterDestroy
public static async removeReactionFromCommentCache(
model: Reaction,
ctx: APIContext["context"] & InstanceDestroyOptions
) {
const { transaction } = ctx;
const lock = transaction
? {
level: transaction.LOCK.UPDATE,
of: Comment,
}
: undefined;
const comment = await Comment.findByPk(model.commentId, {
transaction,
lock,
});
if (!comment) {
return;
}
let reactions = cloneDeep(comment.reactions) ?? [];
const reaction = reactions.find((r) => r.emoji === model.emoji);
if (reaction) {
reaction.userIds = reaction.userIds.filter((id) => id !== model.userId);
if (reaction.userIds.length === 0) {
reactions = reactions.filter((r) => r.emoji !== model.emoji);
}
}
comment.reactions = reactions;
// Pass only the fields needed in APIContext; otherwise sequelize props will be overwritten.
const context = createContext({
user: ctx.auth.user,
authType: ctx.auth.type,
...ctx,
});
await comment.saveWithCtx(
context,
{
fields: ["reactions"],
silent: true,
},
{ name: "remove_reaction", data: { emoji: model.emoji } }
);
}
}
export default Reaction;