mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
1 Commits
v1.8.1
...
tom/7920-stars
| Author | SHA1 | Date | |
|---|---|---|---|
| 919b83fa2a |
@@ -1,24 +1,21 @@
|
||||
import { Star, Event } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import { withAPIContext } from "@server/test/support";
|
||||
import starCreator from "./starCreator";
|
||||
|
||||
describe("starCreator", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should create star", async () => {
|
||||
it("should create star for document", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const star = await sequelize.transaction(async (transaction) =>
|
||||
const star = await withAPIContext(user, (ctx) =>
|
||||
starCreator({
|
||||
ctx,
|
||||
documentId: document.id,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -45,12 +42,11 @@ describe("starCreator", () => {
|
||||
index: "P",
|
||||
});
|
||||
|
||||
const star = await sequelize.transaction(async (transaction) =>
|
||||
const star = await withAPIContext(user, (ctx) =>
|
||||
starCreator({
|
||||
ctx,
|
||||
documentId: document.id,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { Sequelize, Transaction, WhereOptions } from "sequelize";
|
||||
import { Star, User, Event } from "@server/models";
|
||||
import { Sequelize, WhereOptions } from "sequelize";
|
||||
import { Star, User } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
type Props = {
|
||||
/** The user creating the star */
|
||||
@@ -11,9 +12,8 @@ type Props = {
|
||||
collectionId?: string;
|
||||
/** The sorted index for the star in the sidebar If no index is provided then it will be at the end */
|
||||
index?: string;
|
||||
/** The IP address of the user creating the star */
|
||||
ip: string;
|
||||
transaction: Transaction;
|
||||
/** The request context */
|
||||
ctx: APIContext;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,8 +27,7 @@ export default async function starCreator({
|
||||
user,
|
||||
documentId,
|
||||
collectionId,
|
||||
ip,
|
||||
transaction,
|
||||
ctx,
|
||||
...rest
|
||||
}: Props): Promise<Star> {
|
||||
let { index } = rest;
|
||||
@@ -47,14 +46,14 @@ export default async function starCreator({
|
||||
Sequelize.literal('"star"."index" collate "C"'),
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
transaction,
|
||||
transaction: ctx.state.transaction,
|
||||
});
|
||||
|
||||
// create a star at the beginning of the list
|
||||
index = fractionalIndex(null, stars.length ? stars[0].index : null);
|
||||
}
|
||||
|
||||
const [star, isCreated] = await Star.findOrCreate({
|
||||
const [star] = await Star.findOrCreateWithCtx(ctx, {
|
||||
where: documentId
|
||||
? {
|
||||
userId: user.id,
|
||||
@@ -67,24 +66,7 @@ export default async function starCreator({
|
||||
defaults: {
|
||||
index,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (isCreated) {
|
||||
await Event.create(
|
||||
{
|
||||
name: "stars.create",
|
||||
teamId: user.teamId,
|
||||
modelId: star.id,
|
||||
userId: user.id,
|
||||
actorId: user.id,
|
||||
documentId,
|
||||
collectionId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
return star;
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Event, Star } from "@server/models";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import starDestroyer from "./starDestroyer";
|
||||
|
||||
describe("starDestroyer", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should destroy existing star", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const star = await Star.create({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
index: "P",
|
||||
});
|
||||
|
||||
await starDestroyer({
|
||||
star,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
|
||||
const count = await Star.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
expect(count).toEqual(0);
|
||||
|
||||
const event = await Event.findLatest({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
expect(event!.name).toEqual("stars.delete");
|
||||
expect(event!.modelId).toEqual(star.id);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { Event, Star, User } from "@server/models";
|
||||
|
||||
type Props = {
|
||||
/** The user destroying the star */
|
||||
user: User;
|
||||
/** The star to destroy */
|
||||
star: Star;
|
||||
/** The IP address of the user creating the star */
|
||||
ip: string;
|
||||
/** Optional existing transaction */
|
||||
transaction?: Transaction;
|
||||
};
|
||||
|
||||
/**
|
||||
* This command destroys a document star. This just removes the star itself and
|
||||
* does not touch the document
|
||||
*
|
||||
* @param Props The properties of the star to destroy
|
||||
* @returns void
|
||||
*/
|
||||
export default async function starDestroyer({
|
||||
user,
|
||||
star,
|
||||
ip,
|
||||
transaction,
|
||||
}: Props): Promise<Star> {
|
||||
await star.destroy({ transaction });
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "stars.delete",
|
||||
modelId: star.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
userId: star.userId,
|
||||
documentId: star.documentId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
return star;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Event, Star } from "@server/models";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import starUpdater from "./starUpdater";
|
||||
|
||||
describe("starUpdater", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should update (move) existing star", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
let star = await Star.create({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
index: "P",
|
||||
});
|
||||
|
||||
star = await starUpdater({
|
||||
star,
|
||||
index: "h",
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
|
||||
const event = await Event.findLatest({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
expect(star.documentId).toEqual(document.id);
|
||||
expect(star.userId).toEqual(user.id);
|
||||
expect(star.index).toEqual("h");
|
||||
expect(event!.name).toEqual("stars.update");
|
||||
expect(event!.modelId).toEqual(star.id);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { Event, Star, User } from "@server/models";
|
||||
|
||||
type Props = {
|
||||
/** The user updating the star */
|
||||
user: User;
|
||||
/** The existing star */
|
||||
star: Star;
|
||||
/** The index to star the document at */
|
||||
index: string;
|
||||
/** The IP address of the user creating the star */
|
||||
ip: string;
|
||||
/** Optional existing transaction */
|
||||
transaction?: Transaction;
|
||||
};
|
||||
|
||||
/**
|
||||
* This command updates a "starred" document. A star can only be moved to a new
|
||||
* index (reordered) once created.
|
||||
*
|
||||
* @param Props The properties of the star to update
|
||||
* @returns Star The updated star
|
||||
*/
|
||||
export default async function starUpdater({
|
||||
user,
|
||||
star,
|
||||
index,
|
||||
ip,
|
||||
transaction,
|
||||
}: Props): Promise<Star> {
|
||||
star.index = index;
|
||||
await star.save({ transaction });
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "stars.update",
|
||||
modelId: star.id,
|
||||
userId: star.userId,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
documentId: star.documentId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
return star;
|
||||
}
|
||||
@@ -19,6 +19,8 @@ class Star extends IdModel<
|
||||
InferAttributes<Star>,
|
||||
Partial<InferCreationAttributes<Star>>
|
||||
> {
|
||||
static eventNamespace = "stars";
|
||||
|
||||
@Length({
|
||||
max: 256,
|
||||
msg: `index must be 256 characters or less`,
|
||||
|
||||
@@ -4,10 +4,12 @@ import isArray from "lodash/isArray";
|
||||
import isObject from "lodash/isObject";
|
||||
import pick from "lodash/pick";
|
||||
import {
|
||||
Attributes,
|
||||
CreateOptions,
|
||||
CreationAttributes,
|
||||
DataTypes,
|
||||
FindOptions,
|
||||
FindOrCreateOptions,
|
||||
InstanceDestroyOptions,
|
||||
InstanceUpdateOptions,
|
||||
ModelStatic,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
AfterCreate,
|
||||
AfterDestroy,
|
||||
AfterUpdate,
|
||||
AfterUpsert,
|
||||
BeforeCreate,
|
||||
Model as SequelizeModel,
|
||||
} from "sequelize-typescript";
|
||||
@@ -47,8 +50,9 @@ class Model<
|
||||
* This is the same as calling `set` and then calling `save`.
|
||||
*/
|
||||
public updateWithCtx(ctx: APIContext, keys: Partial<TModelAttributes>) {
|
||||
this.set(keys);
|
||||
this.cacheChangeset();
|
||||
return this.update(keys, ctx.context as InstanceUpdateOptions);
|
||||
return this.save(ctx.context as SaveOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +63,21 @@ class Model<
|
||||
return this.destroy(ctx.context as InstanceDestroyOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a row that matches the query, or build and save the row if none is found
|
||||
* The successful result of the promise will be (instance, created) - Make sure to use `.then(([...]))`
|
||||
*/
|
||||
public static findOrCreateWithCtx<M extends Model>(
|
||||
this: ModelStatic<M>,
|
||||
ctx: APIContext,
|
||||
options: FindOrCreateOptions<Attributes<M>, CreationAttributes<M>>
|
||||
) {
|
||||
return this.findOrCreate({
|
||||
...options,
|
||||
...ctx.context,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a new model instance and calls save on it.
|
||||
*/
|
||||
@@ -83,6 +102,14 @@ class Model<
|
||||
await this.insertEvent("create", model, context);
|
||||
}
|
||||
|
||||
@AfterUpsert
|
||||
static async afterUpsertEvent<T extends Model>(
|
||||
model: T,
|
||||
context: APIContext["context"]
|
||||
) {
|
||||
await this.insertEvent("create", model, context);
|
||||
}
|
||||
|
||||
@AfterUpdate
|
||||
static async afterUpdateEvent<T extends Model>(
|
||||
model: T,
|
||||
@@ -267,7 +294,14 @@ class Model<
|
||||
* Cache the current changeset for later use.
|
||||
*/
|
||||
protected cacheChangeset() {
|
||||
this.previousChangeset = this.changeset;
|
||||
const previous = this.changeset;
|
||||
|
||||
if (
|
||||
Object.keys(previous.attributes).length > 0 ||
|
||||
Object.keys(previous.previous).length > 0
|
||||
) {
|
||||
this.previousChangeset = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import Router from "koa-router";
|
||||
import { Sequelize } from "sequelize";
|
||||
import starCreator from "@server/commands/starCreator";
|
||||
import starDestroyer from "@server/commands/starDestroyer";
|
||||
import starUpdater from "@server/commands/starUpdater";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
@@ -46,12 +44,11 @@ router.post(
|
||||
}
|
||||
|
||||
const star = await starCreator({
|
||||
ctx,
|
||||
user,
|
||||
documentId,
|
||||
collectionId,
|
||||
ip: ctx.request.ip,
|
||||
index,
|
||||
transaction,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
@@ -130,19 +127,13 @@ router.post(
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
let star = await Star.findByPk(id, {
|
||||
const star = await Star.findByPk(id, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
authorize(user, "update", star);
|
||||
|
||||
star = await starUpdater({
|
||||
user,
|
||||
star,
|
||||
ip: ctx.request.ip,
|
||||
index,
|
||||
transaction,
|
||||
});
|
||||
await star.updateWithCtx(ctx, { index });
|
||||
|
||||
ctx.body = {
|
||||
data: presentStar(star),
|
||||
@@ -167,7 +158,7 @@ router.post(
|
||||
});
|
||||
authorize(user, "delete", star);
|
||||
|
||||
await starDestroyer({ user, star, ip: ctx.request.ip, transaction });
|
||||
await star.destroyWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { Transaction } from "sequelize";
|
||||
import sharedEnv from "@shared/env";
|
||||
import { createContext } from "@server/context";
|
||||
import env from "@server/env";
|
||||
import { User } from "@server/models";
|
||||
import onerror from "@server/onerror";
|
||||
@@ -45,6 +46,7 @@ export function withAPIContext<T>(
|
||||
transaction,
|
||||
};
|
||||
return fn({
|
||||
...createContext(user, transaction),
|
||||
state,
|
||||
request: {
|
||||
ip: faker.internet.ip(),
|
||||
|
||||
Reference in New Issue
Block a user