feat: Pin to home (#2880)

This commit is contained in:
Tom Moor
2021-12-30 16:54:02 -08:00
committed by GitHub
parent 5be2eb75f3
commit eb0c324da8
57 changed files with 1884 additions and 819 deletions

View File

@@ -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;
}

View 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);
});
});

View 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;
}

View 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);
});
});

View 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;
}

View 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;
}

View 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");
},
};

View File

@@ -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,
});

View File

@@ -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
View 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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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
View 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;
});

View File

@@ -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

View File

@@ -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
View 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,
};
}

View File

@@ -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, {

View File

@@ -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",

View File

@@ -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);

View File

@@ -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();

View File

@@ -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]),
});

View File

@@ -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
View 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;

View File

@@ -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";

View File

@@ -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

View File

@@ -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);
}