feat: Pin to home (#2880)
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { Document, Attachment, Collection, User, Event } from "@server/models";
|
||||
import { Document, Attachment, Collection, Pin, Event } from "@server/models";
|
||||
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
||||
import { sequelize } from "../sequelize";
|
||||
import pinDestroyer from "./pinDestroyer";
|
||||
|
||||
async function copyAttachments(
|
||||
document: Document,
|
||||
@@ -51,36 +52,30 @@ export default async function documentMover({
|
||||
}: {
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
|
||||
user: User;
|
||||
document: Document;
|
||||
document: any;
|
||||
collectionId: string;
|
||||
parentDocumentId?: string | null;
|
||||
index?: number;
|
||||
ip: string;
|
||||
}) {
|
||||
let transaction: Transaction | undefined;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message
|
||||
const collectionChanged = collectionId !== document.collectionId;
|
||||
const previousCollectionId = document.collectionId;
|
||||
const result = {
|
||||
collections: [],
|
||||
documents: [],
|
||||
collectionChanged,
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'template' does not exist on type 'Docume... Remove this comment to see the full error message
|
||||
if (document.template) {
|
||||
if (!collectionChanged) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message
|
||||
document.collectionId = collectionId;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'parentDocumentId' does not exist on type... Remove this comment to see the full error message
|
||||
document.parentDocumentId = null;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'lastModifiedById' does not exist on type... Remove this comment to see the full error message
|
||||
document.lastModifiedById = user.id;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedBy' does not exist on type 'Docum... Remove this comment to see the full error message
|
||||
document.updatedBy = user;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'save' does not exist on type 'Document'.
|
||||
await document.save();
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'Document' is not assignable to p... Remove this comment to see the full error message
|
||||
result.documents.push(document);
|
||||
@@ -89,7 +84,6 @@ export default async function documentMover({
|
||||
transaction = await sequelize.transaction();
|
||||
|
||||
// remove from original collection
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message
|
||||
const collection = await Collection.findByPk(document.collectionId, {
|
||||
transaction,
|
||||
paranoid: false,
|
||||
@@ -107,9 +101,7 @@ export default async function documentMover({
|
||||
// We need to compensate for this when reordering
|
||||
const toIndex =
|
||||
index !== undefined &&
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'parentDocumentId' does not exist on type... Remove this comment to see the full error message
|
||||
document.parentDocumentId === parentDocumentId &&
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message
|
||||
document.collectionId === collectionId &&
|
||||
fromIndex < index
|
||||
? index - 1
|
||||
@@ -121,21 +113,17 @@ export default async function documentMover({
|
||||
await collection.save({
|
||||
transaction,
|
||||
});
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'Document'.
|
||||
document.text = await copyAttachments(document, {
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
// add to new collection (may be the same)
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message
|
||||
document.collectionId = collectionId;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'parentDocumentId' does not exist on type... Remove this comment to see the full error message
|
||||
document.parentDocumentId = parentDocumentId;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'lastModifiedById' does not exist on type... Remove this comment to see the full error message
|
||||
document.lastModifiedById = user.id;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedBy' does not exist on type 'Docum... Remove this comment to see the full error message
|
||||
document.updatedBy = user;
|
||||
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Collection' refers to a value, but is being used ... Remove this comment to see the full error message
|
||||
const newCollection: Collection = collectionChanged
|
||||
? await Collection.scope({
|
||||
@@ -180,15 +168,27 @@ export default async function documentMover({
|
||||
);
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
|
||||
await loopChildren(document.id);
|
||||
|
||||
const pin = await Pin.findOne({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
collectionId: previousCollectionId,
|
||||
},
|
||||
});
|
||||
|
||||
if (pin) {
|
||||
await pinDestroyer({
|
||||
user,
|
||||
pin,
|
||||
ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'save' does not exist on type 'Document'.
|
||||
await document.save({
|
||||
transaction,
|
||||
});
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'Docu... Remove this comment to see the full error message
|
||||
document.collection = newCollection;
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'Document' is not assignable to p... Remove this comment to see the full error message
|
||||
result.documents.push(document);
|
||||
@@ -208,10 +208,8 @@ export default async function documentMover({
|
||||
await Event.create({
|
||||
name: "documents.move",
|
||||
actorId: user.id,
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
|
||||
documentId: document.id,
|
||||
collectionId,
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'teamId' does not exist on type 'Document... Remove this comment to see the full error message
|
||||
teamId: document.teamId,
|
||||
data: {
|
||||
title: document.title,
|
||||
@@ -222,6 +220,7 @@ export default async function documentMover({
|
||||
},
|
||||
ip,
|
||||
});
|
||||
|
||||
// we need to send all updated models back to the client
|
||||
return result;
|
||||
}
|
||||
|
||||
55
server/commands/pinCreator.test.ts
Normal file
55
server/commands/pinCreator.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Event } from "@server/models";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import { flushdb } from "@server/test/support";
|
||||
import pinCreator from "./pinCreator";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
describe("pinCreator", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should create pin to home", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const pin = await pinCreator({
|
||||
documentId: document.id,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
|
||||
const event = await Event.findOne();
|
||||
expect(pin.documentId).toEqual(document.id);
|
||||
expect(pin.collectionId).toEqual(null);
|
||||
expect(pin.createdById).toEqual(user.id);
|
||||
expect(pin.index).toEqual("P");
|
||||
expect(event.name).toEqual("pins.create");
|
||||
expect(event.modelId).toEqual(pin.id);
|
||||
});
|
||||
|
||||
it("should create pin to collection", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const pin = await pinCreator({
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
|
||||
const event = await Event.findOne();
|
||||
expect(pin.documentId).toEqual(document.id);
|
||||
expect(pin.collectionId).toEqual(document.collectionId);
|
||||
expect(pin.createdById).toEqual(user.id);
|
||||
expect(pin.index).toEqual("P");
|
||||
expect(event.name).toEqual("pins.create");
|
||||
expect(event.modelId).toEqual(pin.id);
|
||||
expect(event.collectionId).toEqual(pin.collectionId);
|
||||
});
|
||||
});
|
||||
97
server/commands/pinCreator.ts
Normal file
97
server/commands/pinCreator.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import { Pin, Event } from "@server/models";
|
||||
import { sequelize, Op } from "@server/sequelize";
|
||||
|
||||
const MAX_PINS = 8;
|
||||
|
||||
type Props = {
|
||||
/** The user creating the pin */
|
||||
user: any;
|
||||
/** The document to pin */
|
||||
documentId: string;
|
||||
/** The collection to pin the document in. If no collection is provided then it will be pinned to home */
|
||||
collectionId?: string | undefined;
|
||||
/** The index to pin the document at. If no index is provided then it will be pinned to the end of the collection */
|
||||
index?: string;
|
||||
/** The IP address of the user creating the pin */
|
||||
ip: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* This command creates a "pinned" document via the pin relation. A document can
|
||||
* be pinned to a collection or to the home screen.
|
||||
*
|
||||
* @param Props The properties of the pin to create
|
||||
* @returns Pin The pin that was created
|
||||
*/
|
||||
export default async function pinCreator({
|
||||
user,
|
||||
documentId,
|
||||
collectionId,
|
||||
ip,
|
||||
...rest
|
||||
}: Props): Promise<any> {
|
||||
let { index } = rest;
|
||||
const where = {
|
||||
teamId: user.teamId,
|
||||
...(collectionId ? { collectionId } : { collectionId: { [Op.eq]: null } }),
|
||||
};
|
||||
|
||||
const count = await Pin.count({ where });
|
||||
if (count >= MAX_PINS) {
|
||||
throw ValidationError(`You cannot pin more than ${MAX_PINS} documents`);
|
||||
}
|
||||
|
||||
if (!index) {
|
||||
const pins = await Pin.findAll({
|
||||
where,
|
||||
attributes: ["id", "index", "updatedAt"],
|
||||
limit: 1,
|
||||
order: [
|
||||
// using LC_COLLATE:"C" because we need byte order to drive the sorting
|
||||
// find only the last pin so we can create an index after it
|
||||
sequelize.literal('"pins"."index" collate "C" DESC'),
|
||||
["updatedAt", "ASC"],
|
||||
],
|
||||
});
|
||||
|
||||
// create a pin at the end of the list
|
||||
index = fractionalIndex(pins.length ? pins[0].index : null, null);
|
||||
}
|
||||
|
||||
const transaction = await sequelize.transaction();
|
||||
let pin;
|
||||
|
||||
try {
|
||||
pin = await Pin.create(
|
||||
{
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId,
|
||||
documentId,
|
||||
index,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "pins.create",
|
||||
modelId: pin.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
documentId,
|
||||
collectionId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return pin;
|
||||
}
|
||||
38
server/commands/pinDestroyer.test.ts
Normal file
38
server/commands/pinDestroyer.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Pin, Event } from "@server/models";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import { flushdb } from "@server/test/support";
|
||||
import pinDestroyer from "./pinDestroyer";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
describe("pinCreator", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should destroy existing pin", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const pin = await Pin.create({
|
||||
teamId: document.teamId,
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
createdById: user.id,
|
||||
index: "P",
|
||||
});
|
||||
|
||||
await pinDestroyer({
|
||||
pin,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
|
||||
const count = await Pin.count();
|
||||
expect(count).toEqual(0);
|
||||
|
||||
const event = await Event.findOne();
|
||||
expect(event.name).toEqual("pins.delete");
|
||||
expect(event.modelId).toEqual(pin.id);
|
||||
});
|
||||
});
|
||||
54
server/commands/pinDestroyer.ts
Normal file
54
server/commands/pinDestroyer.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { Event } from "@server/models";
|
||||
import { sequelize } from "@server/sequelize";
|
||||
|
||||
type Props = {
|
||||
/** The user destroying the pin */
|
||||
user: any;
|
||||
/** The pin to destroy */
|
||||
pin: any;
|
||||
/** The IP address of the user creating the pin */
|
||||
ip: string;
|
||||
/** Optional existing transaction */
|
||||
transaction?: Transaction;
|
||||
};
|
||||
|
||||
/**
|
||||
* This command destroys a document pin. This just removes the pin itself and
|
||||
* does not touch the document
|
||||
*
|
||||
* @param Props The properties of the pin to destroy
|
||||
* @returns void
|
||||
*/
|
||||
export default async function pinDestroyer({
|
||||
user,
|
||||
pin,
|
||||
ip,
|
||||
transaction: t,
|
||||
}: Props): Promise<any> {
|
||||
const transaction = t || (await sequelize.transaction());
|
||||
|
||||
try {
|
||||
await Event.create(
|
||||
{
|
||||
name: "pins.delete",
|
||||
modelId: pin.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
documentId: pin.documentId,
|
||||
collectionId: pin.collectionId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await pin.destroy({ transaction });
|
||||
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return pin;
|
||||
}
|
||||
53
server/commands/pinUpdater.ts
Normal file
53
server/commands/pinUpdater.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Event } from "@server/models";
|
||||
import { sequelize } from "@server/sequelize";
|
||||
|
||||
type Props = {
|
||||
/** The user updating the pin */
|
||||
user: any;
|
||||
/** The existing pin */
|
||||
pin: any;
|
||||
/** The index to pin the document at */
|
||||
index?: string;
|
||||
/** The IP address of the user creating the pin */
|
||||
ip: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* This command updates a "pinned" document. A pin can only be moved to a new
|
||||
* index (reordered) once created.
|
||||
*
|
||||
* @param Props The properties of the pin to update
|
||||
* @returns Pin The updated pin
|
||||
*/
|
||||
export default async function pinUpdater({
|
||||
user,
|
||||
pin,
|
||||
index,
|
||||
ip,
|
||||
}: Props): Promise<any> {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
pin.index = index;
|
||||
await pin.save({ transaction });
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "pins.update",
|
||||
modelId: pin.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
documentId: pin.documentId,
|
||||
collectionId: pin.collectionId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return pin;
|
||||
}
|
||||
98
server/migrations/20211221031430-create-pins.js
Normal file
98
server/migrations/20211221031430-create-pins.js
Normal file
@@ -0,0 +1,98 @@
|
||||
"use strict";
|
||||
|
||||
const { v4 } = require("uuid");
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.createTable("pins", {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
documentId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
onDelete: "cascade",
|
||||
references: {
|
||||
model: "documents",
|
||||
},
|
||||
},
|
||||
collectionId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true,
|
||||
onDelete: "cascade",
|
||||
references: {
|
||||
model: "collections",
|
||||
},
|
||||
},
|
||||
teamId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
onDelete: "cascade",
|
||||
references: {
|
||||
model: "teams",
|
||||
},
|
||||
},
|
||||
createdById: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "users",
|
||||
},
|
||||
},
|
||||
index: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
});
|
||||
await queryInterface.addIndex("pins", ["collectionId"]);
|
||||
|
||||
const createdAt = new Date();
|
||||
const [documents] = await queryInterface.sequelize.query(`SELECT "id","collectionId","teamId","pinnedById" FROM documents WHERE "pinnedById" IS NOT NULL`);
|
||||
|
||||
for (const document of documents) {
|
||||
await queryInterface.sequelize.query(`
|
||||
INSERT INTO pins (
|
||||
"id",
|
||||
"documentId",
|
||||
"collectionId",
|
||||
"teamId",
|
||||
"createdById",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
)
|
||||
VALUES (
|
||||
:id,
|
||||
:documentId,
|
||||
:collectionId,
|
||||
:teamId,
|
||||
:createdById,
|
||||
:createdAt,
|
||||
:updatedAt
|
||||
)
|
||||
`, {
|
||||
replacements: {
|
||||
id: v4(),
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
createdById: document.pinnedById,
|
||||
updatedAt: createdAt,
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
return queryInterface.dropTable("pins");
|
||||
},
|
||||
};
|
||||
@@ -142,6 +142,7 @@ Document.associate = (models) => {
|
||||
as: "updatedBy",
|
||||
foreignKey: "lastModifiedById",
|
||||
});
|
||||
/** Deprecated – use Pins relationship instead */
|
||||
Document.belongsTo(models.User, {
|
||||
as: "pinnedBy",
|
||||
foreignKey: "pinnedById",
|
||||
@@ -180,8 +181,7 @@ Document.associate = (models) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type.
|
||||
Document.addScope("withCollection", (userId, paranoid = true) => {
|
||||
Document.addScope("withCollection", (userId: string, paranoid = true) => {
|
||||
if (userId) {
|
||||
return {
|
||||
include: [
|
||||
@@ -219,8 +219,7 @@ Document.associate = (models) => {
|
||||
},
|
||||
],
|
||||
});
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type.
|
||||
Document.addScope("withViews", (userId) => {
|
||||
Document.addScope("withViews", (userId: string) => {
|
||||
if (!userId) return {};
|
||||
return {
|
||||
include: [
|
||||
@@ -236,8 +235,7 @@ Document.associate = (models) => {
|
||||
],
|
||||
};
|
||||
});
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type.
|
||||
Document.addScope("withStarred", (userId) => ({
|
||||
Document.addScope("withStarred", (userId: string) => ({
|
||||
include: [
|
||||
{
|
||||
model: models.Star,
|
||||
@@ -250,20 +248,40 @@ Document.associate = (models) => {
|
||||
},
|
||||
],
|
||||
}));
|
||||
Document.defaultScopeWithUser = (userId: string) => {
|
||||
const starredScope = {
|
||||
method: ["withStarred", userId],
|
||||
};
|
||||
const collectionScope = {
|
||||
method: ["withCollection", userId],
|
||||
};
|
||||
const viewScope = {
|
||||
method: ["withViews", userId],
|
||||
};
|
||||
return Document.scope(
|
||||
"defaultScope",
|
||||
starredScope,
|
||||
collectionScope,
|
||||
viewScope
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'id' implicitly has an 'any' type.
|
||||
Document.findByPk = async function (id, options = {}) {
|
||||
Document.findByPk = async function (
|
||||
id: string,
|
||||
options: {
|
||||
userId?: string;
|
||||
paranoid?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
// allow default preloading of collection membership if `userId` is passed in find options
|
||||
// almost every endpoint needs the collection membership to determine policy permissions.
|
||||
const scope = this.scope(
|
||||
"withUnpublished",
|
||||
{
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'userId' does not exist on type '{}'.
|
||||
method: ["withCollection", options.userId, options.paranoid],
|
||||
},
|
||||
{
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'userId' does not exist on type '{}'.
|
||||
method: ["withViews", options.userId],
|
||||
}
|
||||
);
|
||||
@@ -275,10 +293,13 @@ Document.findByPk = async function (id, options = {}) {
|
||||
},
|
||||
...options,
|
||||
});
|
||||
} else if (id.match(SLUG_URL_REGEX)) {
|
||||
}
|
||||
|
||||
const match = id.match(SLUG_URL_REGEX);
|
||||
if (match) {
|
||||
return scope.findOne({
|
||||
where: {
|
||||
urlId: id.match(SLUG_URL_REGEX)[1],
|
||||
urlId: match[1],
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -71,8 +71,6 @@ Event.ACTIVITY_EVENTS = [
|
||||
"documents.publish",
|
||||
"documents.archive",
|
||||
"documents.unarchive",
|
||||
"documents.pin",
|
||||
"documents.unpin",
|
||||
"documents.move",
|
||||
"documents.delete",
|
||||
"documents.permanent_delete",
|
||||
@@ -99,8 +97,6 @@ Event.AUDIT_EVENTS = [
|
||||
"documents.update",
|
||||
"documents.archive",
|
||||
"documents.unarchive",
|
||||
"documents.pin",
|
||||
"documents.unpin",
|
||||
"documents.move",
|
||||
"documents.delete",
|
||||
"documents.permanent_delete",
|
||||
@@ -108,6 +104,9 @@ Event.AUDIT_EVENTS = [
|
||||
"groups.create",
|
||||
"groups.update",
|
||||
"groups.delete",
|
||||
"pins.create",
|
||||
"pins.update",
|
||||
"pins.delete",
|
||||
"revisions.create",
|
||||
"shares.create",
|
||||
"shares.update",
|
||||
|
||||
50
server/models/Pin.ts
Normal file
50
server/models/Pin.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
|
||||
const Pin = sequelize.define(
|
||||
"pins",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
teamId: {
|
||||
type: DataTypes.UUID,
|
||||
},
|
||||
documentId: {
|
||||
type: DataTypes.UUID,
|
||||
},
|
||||
collectionId: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: null,
|
||||
},
|
||||
index: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
}
|
||||
);
|
||||
|
||||
Pin.associate = (models: any) => {
|
||||
Pin.belongsTo(models.Document, {
|
||||
as: "document",
|
||||
foreignKey: "documentId",
|
||||
});
|
||||
Pin.belongsTo(models.Collection, {
|
||||
as: "collection",
|
||||
foreignKey: "collectionId",
|
||||
});
|
||||
Pin.belongsTo(models.Team, {
|
||||
as: "team",
|
||||
foreignKey: "teamId",
|
||||
});
|
||||
Pin.belongsTo(models.User, {
|
||||
as: "createdBy",
|
||||
foreignKey: "createdById",
|
||||
});
|
||||
};
|
||||
|
||||
export default Pin;
|
||||
@@ -14,6 +14,7 @@ import Integration from "./Integration";
|
||||
import IntegrationAuthentication from "./IntegrationAuthentication";
|
||||
import Notification from "./Notification";
|
||||
import NotificationSetting from "./NotificationSetting";
|
||||
import Pin from "./Pin";
|
||||
import Revision from "./Revision";
|
||||
import SearchQuery from "./SearchQuery";
|
||||
import Share from "./Share";
|
||||
@@ -39,6 +40,7 @@ const models = {
|
||||
IntegrationAuthentication,
|
||||
Notification,
|
||||
NotificationSetting,
|
||||
Pin,
|
||||
Revision,
|
||||
SearchQuery,
|
||||
Share,
|
||||
@@ -73,6 +75,7 @@ export {
|
||||
IntegrationAuthentication,
|
||||
Notification,
|
||||
NotificationSetting,
|
||||
Pin,
|
||||
Revision,
|
||||
SearchQuery,
|
||||
Share,
|
||||
|
||||
@@ -90,6 +90,15 @@ allow(User, ["pin", "unpin"], Document, (user, document) => {
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
allow(User, ["pinToHome"], Document, (user, document) => {
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
if (document.template) return false;
|
||||
if (!document.publishedAt) return false;
|
||||
|
||||
return user.teamId === document.teamId && user.isAdmin;
|
||||
});
|
||||
|
||||
allow(User, "delete", Document, (user, document) => {
|
||||
if (user.isViewer) return false;
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
@@ -14,6 +14,7 @@ import "./collection";
|
||||
import "./document";
|
||||
import "./integration";
|
||||
import "./notificationSetting";
|
||||
import "./pins";
|
||||
import "./searchQuery";
|
||||
import "./share";
|
||||
import "./user";
|
||||
|
||||
9
server/policies/pins.ts
Normal file
9
server/policies/pins.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { User, Pin } from "@server/models";
|
||||
import policy from "./policy";
|
||||
|
||||
const { allow } = policy;
|
||||
|
||||
allow(User, ["update", "delete"], Pin, (user, pin) => {
|
||||
if (user.teamId === pin.teamId && user.isAdmin) return true;
|
||||
return false;
|
||||
});
|
||||
@@ -26,7 +26,7 @@ async function replaceImageAttachments(text: string) {
|
||||
|
||||
export default async function present(
|
||||
document: any,
|
||||
options: Options | null | undefined
|
||||
options: Options | null | undefined = {}
|
||||
) {
|
||||
options = {
|
||||
isPublic: false,
|
||||
@@ -58,7 +58,6 @@ export default async function present(
|
||||
starred: document.starred ? !!document.starred.length : undefined,
|
||||
revision: document.revisionCount,
|
||||
fullWidth: document.fullWidth,
|
||||
pinned: undefined,
|
||||
collectionId: undefined,
|
||||
parentDocumentId: undefined,
|
||||
lastViewedAt: undefined,
|
||||
@@ -69,8 +68,6 @@ export default async function present(
|
||||
}
|
||||
|
||||
if (!options.isPublic) {
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'boolean' is not assignable to type 'undefine... Remove this comment to see the full error message
|
||||
data.pinned = !!document.pinnedById;
|
||||
data.collectionId = document.collectionId;
|
||||
data.parentDocumentId = document.parentDocumentId;
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'UserPresentation | null | undefined' is not ... Remove this comment to see the full error message
|
||||
|
||||
@@ -10,6 +10,7 @@ import presentGroupMembership from "./groupMembership";
|
||||
import presentIntegration from "./integration";
|
||||
import presentMembership from "./membership";
|
||||
import presentNotificationSetting from "./notificationSetting";
|
||||
import presentPin from "./pin";
|
||||
import presentPolicies from "./policy";
|
||||
import presentRevision from "./revision";
|
||||
import presentSearchQuery from "./searchQuery";
|
||||
@@ -37,6 +38,7 @@ export {
|
||||
presentMembership,
|
||||
presentNotificationSetting,
|
||||
presentSlackAttachment,
|
||||
presentPin,
|
||||
presentPolicies,
|
||||
presentGroupMembership,
|
||||
presentCollectionGroupMembership,
|
||||
|
||||
10
server/presenters/pin.ts
Normal file
10
server/presenters/pin.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default function present(pin: any) {
|
||||
return {
|
||||
id: pin.id,
|
||||
documentId: pin.documentId,
|
||||
collectionId: pin.collectionId,
|
||||
index: pin.index,
|
||||
createdAt: pin.createdAt,
|
||||
updatedAt: pin.updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
Group,
|
||||
CollectionGroup,
|
||||
GroupUser,
|
||||
Pin,
|
||||
} from "@server/models";
|
||||
import { presentPin } from "@server/presenters";
|
||||
import { Op } from "@server/sequelize";
|
||||
import { Event } from "../../types";
|
||||
|
||||
@@ -81,8 +83,6 @@ export default class WebsocketsProcessor {
|
||||
});
|
||||
}
|
||||
|
||||
case "documents.pin":
|
||||
case "documents.unpin":
|
||||
case "documents.update": {
|
||||
const document = await Document.findByPk(event.documentId, {
|
||||
paranoid: false,
|
||||
@@ -334,6 +334,30 @@ export default class WebsocketsProcessor {
|
||||
.emit("fileOperations.update", event.data);
|
||||
}
|
||||
|
||||
case "pins.create":
|
||||
case "pins.update": {
|
||||
const pin = await Pin.findByPk(event.modelId);
|
||||
return socketio
|
||||
.to(
|
||||
pin.collectionId
|
||||
? `collection-${pin.collectionId}`
|
||||
: `team-${pin.teamId}`
|
||||
)
|
||||
.emit(event.name, presentPin(pin));
|
||||
}
|
||||
|
||||
case "pins.delete": {
|
||||
return socketio
|
||||
.to(
|
||||
event.collectionId
|
||||
? `collection-${event.collectionId}`
|
||||
: `team-${event.teamId}`
|
||||
)
|
||||
.emit(event.name, {
|
||||
modelId: event.modelId,
|
||||
});
|
||||
}
|
||||
|
||||
case "groups.create":
|
||||
case "groups.update": {
|
||||
const group = await Group.findByPk(event.modelId, {
|
||||
|
||||
@@ -26,15 +26,6 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.pin should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.restore should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
@@ -71,15 +62,6 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.unpin should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.unstar should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
|
||||
@@ -57,26 +57,24 @@ router.post("collections.create", auth(), async (ctx) => {
|
||||
|
||||
const user = ctx.state.user;
|
||||
authorize(user, "createCollection", user.team);
|
||||
const collections = await Collection.findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
deletedAt: null,
|
||||
},
|
||||
attributes: ["id", "index", "updatedAt"],
|
||||
limit: 1,
|
||||
order: [
|
||||
// using LC_COLLATE:"C" because we need byte order to drive the sorting
|
||||
sequelize.literal('"collection"."index" collate "C"'),
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
});
|
||||
|
||||
if (index) {
|
||||
assertIndexCharacters(
|
||||
index,
|
||||
"Index characters must be between x20 to x7E ASCII"
|
||||
);
|
||||
assertIndexCharacters(index);
|
||||
} else {
|
||||
const collections = await Collection.findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
deletedAt: null,
|
||||
},
|
||||
attributes: ["id", "index", "updatedAt"],
|
||||
limit: 1,
|
||||
order: [
|
||||
// using LC_COLLATE:"C" because we need byte order to drive the sorting
|
||||
sequelize.literal('"collection"."index" collate "C"'),
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
});
|
||||
|
||||
index = fractionalIndex(
|
||||
null,
|
||||
collections.length ? collections[0].index : null
|
||||
@@ -648,10 +646,7 @@ router.post("collections.move", auth(), async (ctx) => {
|
||||
const id = ctx.body.id;
|
||||
let index = ctx.body.index;
|
||||
assertPresent(index, "index is required");
|
||||
assertIndexCharacters(
|
||||
index,
|
||||
"Index characters must be between x20 to x7E ASCII"
|
||||
);
|
||||
assertIndexCharacters(index);
|
||||
assertUuid(id, "id must be a uuid");
|
||||
const user = ctx.state.user;
|
||||
const collection = await Collection.findByPk(id);
|
||||
|
||||
@@ -843,68 +843,7 @@ describe("#documents.list", () => {
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe("#documents.pinned", () => {
|
||||
it("should return pinned documents", async () => {
|
||||
const { user, document } = await seed();
|
||||
document.pinnedById = user.id;
|
||||
await document.save();
|
||||
const res = await server.post("/api/documents.pinned", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collectionId: document.collectionId,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(document.id);
|
||||
});
|
||||
|
||||
it("should return pinned documents in private collections member of", async () => {
|
||||
const { user, collection, document } = await seed();
|
||||
collection.permission = null;
|
||||
await collection.save();
|
||||
document.pinnedById = user.id;
|
||||
await document.save();
|
||||
await CollectionUser.create({
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
createdById: user.id,
|
||||
permission: "read_write",
|
||||
});
|
||||
const res = await server.post("/api/documents.pinned", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collectionId: document.collectionId,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(document.id);
|
||||
});
|
||||
|
||||
it("should not return pinned documents in private collections not a member of", async () => {
|
||||
const collection = await buildCollection({
|
||||
permission: null,
|
||||
});
|
||||
const user = await buildUser({
|
||||
teamId: collection.teamId,
|
||||
});
|
||||
const res = await server.post("/api/documents.pinned", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collectionId: collection.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/documents.pinned");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
describe("#documents.drafts", () => {
|
||||
it("should return unpublished documents", async () => {
|
||||
const { user, document } = await seed();
|
||||
@@ -1534,39 +1473,7 @@ describe("#documents.starred", () => {
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe("#documents.pin", () => {
|
||||
it("should pin the document", async () => {
|
||||
const { user, document } = await seed();
|
||||
const res = await server.post("/api/documents.pin", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.pinned).toEqual(true);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/documents.pin");
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const { document } = await seed();
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/documents.pin", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
describe("#documents.move", () => {
|
||||
it("should move the document", async () => {
|
||||
const { user, document } = await seed();
|
||||
@@ -1807,41 +1714,7 @@ describe("#documents.restore", () => {
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
describe("#documents.unpin", () => {
|
||||
it("should unpin the document", async () => {
|
||||
const { user, document } = await seed();
|
||||
document.pinnedBy = user;
|
||||
await document.save();
|
||||
const res = await server.post("/api/documents.unpin", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.pinned).toEqual(false);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/documents.unpin");
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const { document } = await seed();
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/documents.unpin", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
describe("#documents.star", () => {
|
||||
it("should star the document", async () => {
|
||||
const { user, document } = await seed();
|
||||
|
||||
@@ -142,22 +142,8 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
|
||||
}
|
||||
|
||||
assertSort(sort, Document);
|
||||
// add the users starred state to the response by default
|
||||
const starredScope = {
|
||||
method: ["withStarred", user.id],
|
||||
};
|
||||
const collectionScope = {
|
||||
method: ["withCollection", user.id],
|
||||
};
|
||||
const viewScope = {
|
||||
method: ["withViews", user.id],
|
||||
};
|
||||
const documents = await Document.scope(
|
||||
"defaultScope",
|
||||
starredScope,
|
||||
collectionScope,
|
||||
viewScope
|
||||
).findAll({
|
||||
|
||||
const documents = await Document.defaultScopeWithUser(user.id).findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
@@ -185,57 +171,6 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post("documents.pinned", auth(), pagination(), async (ctx) => {
|
||||
const { collectionId, sort = "updatedAt" } = ctx.body;
|
||||
let direction = ctx.body.direction;
|
||||
if (direction !== "ASC") direction = "DESC";
|
||||
|
||||
assertUuid(collectionId, "collectionId is required");
|
||||
assertSort(sort, Document);
|
||||
|
||||
const user = ctx.state.user;
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId);
|
||||
authorize(user, "read", collection);
|
||||
const starredScope = {
|
||||
method: ["withStarred", user.id],
|
||||
};
|
||||
const collectionScope = {
|
||||
method: ["withCollection", user.id],
|
||||
};
|
||||
const viewScope = {
|
||||
method: ["withViews", user.id],
|
||||
};
|
||||
const documents = await Document.scope(
|
||||
"defaultScope",
|
||||
starredScope,
|
||||
collectionScope,
|
||||
viewScope
|
||||
).findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
collectionId,
|
||||
pinnedById: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
const data = await Promise.all(
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'document' implicitly has an 'any' type.
|
||||
documents.map((document) => presentDocument(document))
|
||||
);
|
||||
const policies = presentPolicies(user, documents);
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data,
|
||||
policies,
|
||||
};
|
||||
});
|
||||
|
||||
router.post("documents.archived", auth(), pagination(), async (ctx) => {
|
||||
const { sort = "updatedAt" } = ctx.body;
|
||||
|
||||
@@ -807,7 +742,6 @@ router.post("documents.restore", auth(), async (ctx) => {
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
};
|
||||
@@ -916,7 +850,6 @@ router.post("documents.search", auth(), pagination(), async (ctx) => {
|
||||
const data = await Promise.all(
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'result' implicitly has an 'any' type.
|
||||
results.map(async (result) => {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
const document = await presentDocument(result.document);
|
||||
return { ...result, document };
|
||||
})
|
||||
@@ -942,62 +875,6 @@ router.post("documents.search", auth(), pagination(), async (ctx) => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post("documents.pin", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
assertPresent(id, "id is required");
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findByPk(id, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "pin", document);
|
||||
document.pinnedById = user.id;
|
||||
await document.save();
|
||||
await Event.create({
|
||||
name: "documents.pin",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("documents.unpin", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
assertPresent(id, "id is required");
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findByPk(id, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "unpin", document);
|
||||
document.pinnedById = null;
|
||||
await document.save();
|
||||
await Event.create({
|
||||
name: "documents.unpin",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("documents.star", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
assertPresent(id, "id is required");
|
||||
@@ -1095,7 +972,6 @@ router.post("documents.templatize", auth(), async (ctx) => {
|
||||
userId: user.id,
|
||||
});
|
||||
ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
};
|
||||
@@ -1218,7 +1094,6 @@ router.post("documents.update", auth(), async (ctx) => {
|
||||
document.updatedBy = user;
|
||||
document.collection = collection;
|
||||
ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
};
|
||||
@@ -1271,7 +1146,6 @@ router.post("documents.move", auth(), async (ctx) => {
|
||||
ctx.body = {
|
||||
data: {
|
||||
documents: await Promise.all(
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
documents.map((document) => presentDocument(document))
|
||||
),
|
||||
collections: await Promise.all(
|
||||
@@ -1303,7 +1177,6 @@ router.post("documents.archive", auth(), async (ctx) => {
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
};
|
||||
@@ -1388,7 +1261,6 @@ router.post("documents.unpublish", auth(), async (ctx) => {
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
};
|
||||
@@ -1461,7 +1333,6 @@ router.post("documents.import", auth(), async (ctx) => {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'Docu... Remove this comment to see the full error message
|
||||
document.collection = collection;
|
||||
return (ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
});
|
||||
@@ -1537,7 +1408,6 @@ router.post("documents.create", auth(), async (ctx) => {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'Docu... Remove this comment to see the full error message
|
||||
document.collection = collection;
|
||||
return (ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import integrations from "./integrations";
|
||||
import apiWrapper from "./middlewares/apiWrapper";
|
||||
import editor from "./middlewares/editor";
|
||||
import notificationSettings from "./notificationSettings";
|
||||
import pins from "./pins";
|
||||
import revisions from "./revisions";
|
||||
import searches from "./searches";
|
||||
import shares from "./shares";
|
||||
@@ -50,6 +51,7 @@ router.use("/", events.routes());
|
||||
router.use("/", users.routes());
|
||||
router.use("/", collections.routes());
|
||||
router.use("/", documents.routes());
|
||||
router.use("/", pins.routes());
|
||||
router.use("/", revisions.routes());
|
||||
router.use("/", views.routes());
|
||||
router.use("/", hooks.routes());
|
||||
|
||||
156
server/routes/api/pins.ts
Normal file
156
server/routes/api/pins.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import Router from "koa-router";
|
||||
import pinCreator from "@server/commands/pinCreator";
|
||||
import pinDestroyer from "@server/commands/pinDestroyer";
|
||||
import pinUpdater from "@server/commands/pinUpdater";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { Collection, Document, Pin } from "@server/models";
|
||||
import policy from "@server/policies";
|
||||
import {
|
||||
presentPin,
|
||||
presentDocument,
|
||||
presentPolicies,
|
||||
} from "@server/presenters";
|
||||
import { sequelize, Op } from "@server/sequelize";
|
||||
import { assertUuid, assertIndexCharacters } from "@server/validation";
|
||||
import pagination from "./middlewares/pagination";
|
||||
|
||||
const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("pins.create", auth(), async (ctx) => {
|
||||
const { documentId, collectionId } = ctx.body;
|
||||
const { index } = ctx.body;
|
||||
assertUuid(documentId, "documentId is required");
|
||||
|
||||
const { user } = ctx.state;
|
||||
const document = await Document.findByPk(documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
|
||||
if (collectionId) {
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId);
|
||||
authorize(user, "update", collection);
|
||||
authorize(user, "pin", document);
|
||||
} else {
|
||||
authorize(user, "pinToHome", document);
|
||||
}
|
||||
|
||||
if (index) {
|
||||
assertIndexCharacters(index);
|
||||
}
|
||||
|
||||
const pin = await pinCreator({
|
||||
user,
|
||||
documentId,
|
||||
collectionId,
|
||||
ip: ctx.request.ip,
|
||||
index,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentPin(pin),
|
||||
policies: presentPolicies(user, [pin]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("pins.list", auth(), pagination(), async (ctx) => {
|
||||
const { collectionId } = ctx.body;
|
||||
const { user } = ctx.state;
|
||||
|
||||
const [pins, collectionIds] = await Promise.all([
|
||||
Pin.findAll({
|
||||
where: {
|
||||
...(collectionId
|
||||
? { collectionId }
|
||||
: { collectionId: { [Op.eq]: null } }),
|
||||
teamId: user.teamId,
|
||||
},
|
||||
order: [
|
||||
sequelize.literal('"pins"."index" collate "C"'),
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
}),
|
||||
user.collectionIds(),
|
||||
]);
|
||||
|
||||
const documents = await Document.defaultScopeWithUser(user.id).findAll({
|
||||
where: {
|
||||
id: pins.map((pin: any) => pin.documentId),
|
||||
collectionId: collectionIds,
|
||||
},
|
||||
});
|
||||
|
||||
const policies = presentPolicies(user, [...documents, ...pins]);
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: {
|
||||
pins: pins.map(presentPin),
|
||||
documents: await Promise.all(
|
||||
documents.map((document: any) => presentDocument(document))
|
||||
),
|
||||
},
|
||||
policies,
|
||||
};
|
||||
});
|
||||
|
||||
router.post("pins.update", auth(), async (ctx) => {
|
||||
const { id, index } = ctx.body;
|
||||
assertUuid(id, "id is required");
|
||||
|
||||
assertIndexCharacters(index);
|
||||
|
||||
const { user } = ctx.state;
|
||||
let pin = await Pin.findByPk(id);
|
||||
const document = await Document.findByPk(pin.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (pin.collectionId) {
|
||||
authorize(user, "pin", document);
|
||||
} else {
|
||||
authorize(user, "update", pin);
|
||||
}
|
||||
|
||||
pin = await pinUpdater({
|
||||
user,
|
||||
pin,
|
||||
ip: ctx.request.ip,
|
||||
index,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentPin(pin),
|
||||
policies: presentPolicies(user, [pin]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("pins.delete", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
assertUuid(id, "id is required");
|
||||
|
||||
const { user } = ctx.state;
|
||||
const pin = await Pin.findByPk(id);
|
||||
const document = await Document.findByPk(pin.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (pin.collectionId) {
|
||||
authorize(user, "unpin", document);
|
||||
} else {
|
||||
authorize(user, "delete", pin);
|
||||
}
|
||||
|
||||
await pinDestroyer({ user, pin, ip: ctx.request.ip });
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,5 +1,4 @@
|
||||
import Sequelize from "sequelize";
|
||||
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'sequ... Remove this comment to see the full error message
|
||||
import EncryptedField from "sequelize-encrypted";
|
||||
import Logger from "./logging/logger";
|
||||
|
||||
|
||||
@@ -40,8 +40,6 @@ export type DocumentEvent =
|
||||
| "documents.publish"
|
||||
| "documents.delete"
|
||||
| "documents.permanent_delete"
|
||||
| "documents.pin"
|
||||
| "documents.unpin"
|
||||
| "documents.archive"
|
||||
| "documents.unarchive"
|
||||
| "documents.restore"
|
||||
@@ -240,9 +238,19 @@ export type TeamEvent = {
|
||||
ip: string;
|
||||
};
|
||||
|
||||
export type PinEvent = {
|
||||
name: "pins.create" | "pins.update" | "pins.delete";
|
||||
teamId: string;
|
||||
modelId: string;
|
||||
collectionId?: string;
|
||||
actorId: string;
|
||||
ip: string;
|
||||
};
|
||||
|
||||
export type Event =
|
||||
| UserEvent
|
||||
| DocumentEvent
|
||||
| PinEvent
|
||||
| CollectionEvent
|
||||
| CollectionImportEvent
|
||||
| CollectionExportAllEvent
|
||||
|
||||
@@ -83,7 +83,10 @@ export const assertValueInArray = (
|
||||
}
|
||||
};
|
||||
|
||||
export const assertIndexCharacters = (value: string, message?: string) => {
|
||||
export const assertIndexCharacters = (
|
||||
value: string,
|
||||
message = "index must be between x20 to x7E ASCII"
|
||||
) => {
|
||||
if (!validateIndexCharacters(value)) {
|
||||
throw ValidationError(message);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user