@@ -22,5 +22,6 @@
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"./app/test/setup.ts"
|
||||
]
|
||||
],
|
||||
"testEnvironment": "jsdom"
|
||||
}
|
||||
53
app/typings/index.d.ts
vendored
53
app/typings/index.d.ts
vendored
@@ -8,59 +8,6 @@ declare module "sequelize-encrypted";
|
||||
|
||||
declare module "styled-components-breakpoint";
|
||||
|
||||
declare module "formidable/lib/file";
|
||||
|
||||
declare module "socket.io-client";
|
||||
|
||||
declare module "socket.io-redis" {
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
type Config = {
|
||||
pubClient: Redis;
|
||||
subClient: Redis;
|
||||
};
|
||||
|
||||
const socketRedisAdapter: (config: Config) => void;
|
||||
|
||||
export = socketRedisAdapter;
|
||||
}
|
||||
|
||||
declare module "socketio-auth" {
|
||||
import IO from "socket.io";
|
||||
|
||||
type AuthenticatedSocket = IO.Socket & {
|
||||
client: IO.Client & {
|
||||
user: any;
|
||||
};
|
||||
};
|
||||
|
||||
type AuthenticateCallback = (
|
||||
socket: AuthenticatedSocket,
|
||||
data: { token: string },
|
||||
callback: (err: Error | null, allow: boolean) => void
|
||||
) => Promise<void>;
|
||||
|
||||
type PostAuthenticateCallback = (
|
||||
socket: AuthenticatedSocket
|
||||
) => Promise<void>;
|
||||
|
||||
type AuthenticationConfig = {
|
||||
authenticate: AuthenticateCallback;
|
||||
postAuthenticate: PostAuthenticateCallback;
|
||||
};
|
||||
|
||||
const SocketAuth: (io: IO.Server, config: AuthenticationConfig) => void;
|
||||
|
||||
export = SocketAuth;
|
||||
}
|
||||
|
||||
declare module "oy-vey";
|
||||
|
||||
declare module "emoji-regex" {
|
||||
const RegExpFactory: () => RegExp;
|
||||
export = RegExpFactory;
|
||||
}
|
||||
|
||||
declare module "*.png" {
|
||||
const value: any;
|
||||
export = value;
|
||||
|
||||
12
package.json
12
package.json
@@ -31,7 +31,7 @@
|
||||
"url": "https://github.com/sponsors/outline"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12 <=16"
|
||||
"node": ">= 14 <=16"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -149,12 +149,14 @@
|
||||
"react-waypoint": "^10.1.0",
|
||||
"react-window": "^1.8.6",
|
||||
"reakit": "^1.3.10",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rich-markdown-editor": "^11.21.3",
|
||||
"semver": "^7.3.2",
|
||||
"sequelize": "^6.9.0",
|
||||
"sequelize-cli": "^6.3.0",
|
||||
"sequelize-encrypted": "^1.0.0",
|
||||
"sequelize-typescript": "^2.1.1",
|
||||
"slate": "0.45.0",
|
||||
"slate-md-serializer": "5.5.4",
|
||||
"slug": "^4.0.4",
|
||||
@@ -209,7 +211,7 @@
|
||||
"@types/koa-static": "^4.0.2",
|
||||
"@types/markdown-it": "^12.2.3",
|
||||
"@types/natural-sort": "^0.0.21",
|
||||
"@types/node": "^15.12.0",
|
||||
"@types/node": "15.12.2",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
"@types/passport-oauth2": "^1.4.11",
|
||||
"@types/prosemirror-inputrules": "^1.0.4",
|
||||
@@ -242,12 +244,14 @@
|
||||
"@types/tmp": "^0.2.2",
|
||||
"@types/turndown": "^5.0.1",
|
||||
"@types/utf8": "^3.0.0",
|
||||
"@types/validator": "^13.7.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.3.1",
|
||||
"@typescript-eslint/parser": "^5.3.1",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^26.2.2",
|
||||
"babel-jest": "^27.4.5",
|
||||
"babel-loader": "^8.1.0",
|
||||
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
|
||||
"babel-plugin-transform-typescript-metadata": "^0.3.2",
|
||||
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.3",
|
||||
"concurrently": "^6.2.1",
|
||||
"enzyme": "^3.11.0",
|
||||
@@ -264,7 +268,7 @@
|
||||
"fetch-test-server": "^1.1.0",
|
||||
"html-webpack-plugin": "4.5.1",
|
||||
"i18next-parser": "^4.7.0",
|
||||
"jest-cli": "^26.0.0",
|
||||
"jest-cli": "^27.4.5",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"koa-webpack-dev-middleware": "^1.4.5",
|
||||
"koa-webpack-hot-middleware": "^1.0.3",
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
{
|
||||
"presets": [
|
||||
"@babel/preset-react",
|
||||
"@babel/preset-typescript",
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"corejs": {
|
||||
"version": "3",
|
||||
"proposals": true
|
||||
},
|
||||
"targets": {
|
||||
"node": "12"
|
||||
},
|
||||
"useBuiltIns": "usage"
|
||||
"node": "14"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"@babel/preset-typescript"
|
||||
],
|
||||
"plugins": [
|
||||
"transform-class-properties",
|
||||
"tsconfig-paths-module-resolver",
|
||||
"babel-plugin-transform-typescript-metadata",
|
||||
["@babel/plugin-proposal-decorators", { "legacy": true }],
|
||||
[
|
||||
"transform-inline-environment-variables",
|
||||
{
|
||||
@@ -27,6 +22,7 @@
|
||||
"SOURCE_VERSION"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"tsconfig-paths-module-resolver"
|
||||
]
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { onAuthenticatePayload } from "@hocuspocus/server";
|
||||
import { Document } from "@server/models";
|
||||
import Document from "@server/models/Document";
|
||||
import { can } from "@server/policies";
|
||||
import { getUserForJWT } from "@server/utils/jwt";
|
||||
import { AuthenticationError } from "../errors";
|
||||
import policy from "../policies";
|
||||
|
||||
const { can } = policy;
|
||||
|
||||
export default class Authentication {
|
||||
async onAuthenticate({
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { onChangePayload, onLoadDocumentPayload } from "@hocuspocus/server";
|
||||
import invariant from "invariant";
|
||||
import { debounce } from "lodash";
|
||||
import * as Y from "yjs";
|
||||
import Logger from "@server/logging/logger";
|
||||
import { Document } from "@server/models";
|
||||
import Document from "@server/models/Document";
|
||||
import documentUpdater from "../commands/documentUpdater";
|
||||
import markdownToYDoc from "./utils/markdownToYDoc";
|
||||
|
||||
@@ -20,6 +21,7 @@ export default class Persistence {
|
||||
}
|
||||
|
||||
const document = await Document.findByPk(documentId);
|
||||
invariant(document, "Document not found");
|
||||
|
||||
if (document.state) {
|
||||
const ydoc = new Y.Doc();
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
import { Collection, UserAuthentication } from "@server/models";
|
||||
import mailer from "@server/mailer";
|
||||
import Collection from "@server/models/Collection";
|
||||
import UserAuthentication from "@server/models/UserAuthentication";
|
||||
import { buildUser, buildTeam } from "@server/test/factories";
|
||||
import { flushdb } from "@server/test/support";
|
||||
import mailer from "../mailer";
|
||||
import accountProvisioner from "./accountProvisioner";
|
||||
|
||||
jest.mock("../mailer");
|
||||
jest.mock("aws-sdk", () => {
|
||||
const mS3 = {
|
||||
createPresignedPost: jest.fn(),
|
||||
putObject: jest.fn().mockReturnThis(),
|
||||
promise: jest.fn(),
|
||||
};
|
||||
return {
|
||||
S3: jest.fn(() => mS3),
|
||||
Endpoint: jest.fn(),
|
||||
};
|
||||
});
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'mockReset' does not exist on type '(type... Remove this comment to see the full error message
|
||||
mailer.sendTemplate.mockReset();
|
||||
return flushdb();
|
||||
});
|
||||
|
||||
describe("accountProvisioner", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should create a new user and team", async () => {
|
||||
const spy = jest.spyOn(mailer, "sendTemplate");
|
||||
const { user, team, isNewTeam, isNewUser } = await accountProvisioner({
|
||||
ip,
|
||||
user: {
|
||||
@@ -48,7 +37,7 @@ describe("accountProvisioner", () => {
|
||||
scopes: ["read"],
|
||||
},
|
||||
});
|
||||
const authentications = await user.getAuthentications();
|
||||
const authentications = await user.$get("authentications");
|
||||
const auth = authentications[0];
|
||||
expect(auth.accessToken).toEqual("123");
|
||||
expect(auth.scopes.length).toEqual(1);
|
||||
@@ -58,19 +47,22 @@ describe("accountProvisioner", () => {
|
||||
expect(user.username).toEqual("jtester");
|
||||
expect(isNewUser).toEqual(true);
|
||||
expect(isNewTeam).toEqual(true);
|
||||
expect(mailer.sendTemplate).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const collectionCount = await Collection.count();
|
||||
expect(collectionCount).toEqual(1);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("should update exising user and authentication", async () => {
|
||||
const spy = jest.spyOn(mailer, "sendTemplate");
|
||||
const existingTeam = await buildTeam();
|
||||
const providers = await existingTeam.getAuthenticationProviders();
|
||||
const providers = await existingTeam.$get("authenticationProviders");
|
||||
const authenticationProvider = providers[0];
|
||||
const existing = await buildUser({
|
||||
teamId: existingTeam.id,
|
||||
});
|
||||
const authentications = await existing.getAuthentications();
|
||||
const authentications = await existing.$get("authentications");
|
||||
const authentication = authentications[0];
|
||||
const newEmail = "test@example.com";
|
||||
const newUsername = "tname";
|
||||
@@ -98,21 +90,23 @@ describe("accountProvisioner", () => {
|
||||
},
|
||||
});
|
||||
const auth = await UserAuthentication.findByPk(authentication.id);
|
||||
expect(auth.accessToken).toEqual("123");
|
||||
expect(auth.scopes.length).toEqual(1);
|
||||
expect(auth.scopes[0]).toEqual("read");
|
||||
expect(auth?.accessToken).toEqual("123");
|
||||
expect(auth?.scopes.length).toEqual(1);
|
||||
expect(auth?.scopes[0]).toEqual("read");
|
||||
expect(user.email).toEqual(newEmail);
|
||||
expect(user.username).toEqual(newUsername);
|
||||
expect(isNewTeam).toEqual(false);
|
||||
expect(isNewUser).toEqual(false);
|
||||
expect(mailer.sendTemplate).not.toHaveBeenCalled();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
const collectionCount = await Collection.count();
|
||||
expect(collectionCount).toEqual(0);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("should throw an error when authentication provider is disabled", async () => {
|
||||
const existingTeam = await buildTeam();
|
||||
const providers = await existingTeam.getAuthenticationProviders();
|
||||
const providers = await existingTeam.$get("authenticationProviders");
|
||||
const authenticationProvider = providers[0];
|
||||
await authenticationProvider.update({
|
||||
enabled: false,
|
||||
@@ -120,7 +114,7 @@ describe("accountProvisioner", () => {
|
||||
const existing = await buildUser({
|
||||
teamId: existingTeam.id,
|
||||
});
|
||||
const authentications = await existing.getAuthentications();
|
||||
const authentications = await existing.$get("authentications");
|
||||
const authentication = authentications[0];
|
||||
let error;
|
||||
|
||||
@@ -129,7 +123,7 @@ describe("accountProvisioner", () => {
|
||||
ip,
|
||||
user: {
|
||||
name: existing.name,
|
||||
email: existing.email,
|
||||
email: existing.email!,
|
||||
avatarUrl: existing.avatarUrl,
|
||||
},
|
||||
team: {
|
||||
@@ -155,8 +149,9 @@ describe("accountProvisioner", () => {
|
||||
});
|
||||
|
||||
it("should create a new user in an existing team", async () => {
|
||||
const spy = jest.spyOn(mailer, "sendTemplate");
|
||||
const team = await buildTeam();
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
const authenticationProviders = await team.$get("authenticationProviders");
|
||||
const authenticationProvider = authenticationProviders[0];
|
||||
const { user, isNewUser } = await accountProvisioner({
|
||||
ip,
|
||||
@@ -181,7 +176,7 @@ describe("accountProvisioner", () => {
|
||||
scopes: ["read"],
|
||||
},
|
||||
});
|
||||
const authentications = await user.getAuthentications();
|
||||
const authentications = await user.$get("authentications");
|
||||
const auth = authentications[0];
|
||||
expect(auth.accessToken).toEqual("123");
|
||||
expect(auth.scopes.length).toEqual(1);
|
||||
@@ -189,9 +184,11 @@ describe("accountProvisioner", () => {
|
||||
expect(user.email).toEqual("jenny@example.com");
|
||||
expect(user.username).toEqual("jtester");
|
||||
expect(isNewUser).toEqual(true);
|
||||
expect(mailer.sendTemplate).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
// should provision welcome collection
|
||||
const collectionCount = await Collection.count();
|
||||
expect(collectionCount).toEqual(1);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import invariant from "invariant";
|
||||
import Sequelize from "sequelize";
|
||||
import { Collection, Team, User } from "@server/models";
|
||||
import { UniqueConstraintError } from "sequelize";
|
||||
import {
|
||||
AuthenticationError,
|
||||
EmailAuthenticationRequiredError,
|
||||
AuthenticationProviderDisabledError,
|
||||
} from "../errors";
|
||||
import mailer from "../mailer";
|
||||
} from "@server/errors";
|
||||
import mailer from "@server/mailer";
|
||||
import { Collection, Team, User } from "@server/models";
|
||||
import teamCreator from "./teamCreator";
|
||||
import userCreator from "./userCreator";
|
||||
|
||||
@@ -15,14 +15,14 @@ type Props = {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl?: string;
|
||||
avatarUrl?: string | null;
|
||||
username?: string;
|
||||
};
|
||||
team: {
|
||||
name: string;
|
||||
domain?: string;
|
||||
subdomain: string;
|
||||
avatarUrl?: string;
|
||||
avatarUrl?: string | null;
|
||||
};
|
||||
authenticationProvider: {
|
||||
name: string;
|
||||
@@ -37,9 +37,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export type AccountProvisionerResult = {
|
||||
// @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;
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Team' refers to a value, but is being used as a t... Remove this comment to see the full error message
|
||||
team: Team;
|
||||
isNewTeam: boolean;
|
||||
isNewUser: boolean;
|
||||
@@ -123,7 +121,7 @@ export default async function accountProvisioner({
|
||||
isNewTeam,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Sequelize.UniqueConstraintError) {
|
||||
if (err instanceof UniqueConstraintError) {
|
||||
const exists = await User.findOne({
|
||||
where: {
|
||||
email: userParams.email,
|
||||
|
||||
@@ -13,7 +13,6 @@ export default async function attachmentCreator({
|
||||
name: string;
|
||||
type: string;
|
||||
buffer: Buffer;
|
||||
// @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;
|
||||
source?: "import";
|
||||
ip: string;
|
||||
|
||||
@@ -7,11 +7,8 @@ export default async function collectionExporter({
|
||||
user,
|
||||
ip,
|
||||
}: {
|
||||
// @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
|
||||
collection?: Collection;
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Team' refers to a value, but is being used as a t... Remove this comment to see the full error message
|
||||
team: Team;
|
||||
// @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;
|
||||
ip: string;
|
||||
}) {
|
||||
@@ -39,6 +36,10 @@ export default async function collectionExporter({
|
||||
});
|
||||
|
||||
fileOperation.user = user;
|
||||
fileOperation.collection = collection;
|
||||
|
||||
if (collection) {
|
||||
fileOperation.collection = collection;
|
||||
}
|
||||
|
||||
return fileOperation;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import collectionImporter from "./collectionImporter";
|
||||
|
||||
jest.mock("../utils/s3");
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("collectionImporter", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ export default async function collectionImporter({
|
||||
ip,
|
||||
}: {
|
||||
file: FileWithPath;
|
||||
// @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;
|
||||
type: "outline";
|
||||
ip: string;
|
||||
@@ -48,7 +47,6 @@ export default async function collectionImporter({
|
||||
// store progress and pointers
|
||||
// @ts-expect-error ts-migrate(2741) FIXME: Property 'string' is missing in type '{}' but requ... Remove this comment to see the full error message
|
||||
const collections: {
|
||||
// @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
|
||||
string: Collection;
|
||||
} = {};
|
||||
// @ts-expect-error ts-migrate(2741) FIXME: Property 'string' is missing in type '{}' but requ... Remove this comment to see the full error message
|
||||
@@ -57,7 +55,6 @@ export default async function collectionImporter({
|
||||
} = {};
|
||||
// @ts-expect-error ts-migrate(2741) FIXME: Property 'string' is missing in type '{}' but requ... Remove this comment to see the full error message
|
||||
const attachments: {
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Attachment' refers to a value, but is being used ... Remove this comment to see the full error message
|
||||
string: Attachment;
|
||||
} = {};
|
||||
|
||||
@@ -186,13 +183,13 @@ export default async function collectionImporter({
|
||||
/(.*)uploads\//,
|
||||
"uploads/"
|
||||
);
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'Document'.
|
||||
|
||||
document.text = document.text
|
||||
.replace(attachmentPath, attachment.redirectUrl)
|
||||
.replace(normalizedAttachmentPath, attachment.redirectUrl)
|
||||
.replace(`/${normalizedAttachmentPath}`, attachment.redirectUrl);
|
||||
|
||||
// does nothing if the document text is unchanged
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'save' does not exist on type 'Document'.
|
||||
await document.save({
|
||||
fields: ["text"],
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import invariant from "invariant";
|
||||
import { Document, Event, User } from "@server/models";
|
||||
|
||||
export default async function documentCreator({
|
||||
@@ -21,18 +22,16 @@ export default async function documentCreator({
|
||||
publish?: boolean;
|
||||
collectionId: string;
|
||||
parentDocumentId?: string;
|
||||
templateDocument?: Document;
|
||||
templateDocument?: Document | null;
|
||||
template?: boolean;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
index?: number;
|
||||
// @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;
|
||||
editorVersion?: string;
|
||||
source?: "import";
|
||||
ip: string;
|
||||
}): Promise<Document> {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
|
||||
const templateId = templateDocument ? templateDocument.id : undefined;
|
||||
const document = await Document.create({
|
||||
parentDocumentId,
|
||||
@@ -47,7 +46,6 @@ export default async function documentCreator({
|
||||
template,
|
||||
templateId,
|
||||
title: templateDocument ? templateDocument.title : title,
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'Document'.
|
||||
text: templateDocument ? templateDocument.text : text,
|
||||
});
|
||||
await Event.create({
|
||||
@@ -83,10 +81,13 @@ export default async function documentCreator({
|
||||
// reload to get all of the data needed to present (user, collection etc)
|
||||
// we need to specify publishedAt to bypass default scope that only returns
|
||||
// published documents
|
||||
return Document.findOne({
|
||||
const doc = await Document.findOne({
|
||||
where: {
|
||||
id: document.id,
|
||||
publishedAt: document.publishedAt,
|
||||
},
|
||||
});
|
||||
invariant(doc, "Document must exist");
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import path from "path";
|
||||
import File from "formidable/lib/file";
|
||||
import { Attachment } from "@server/models";
|
||||
import Attachment from "@server/models/Attachment";
|
||||
import { buildUser } from "@server/test/factories";
|
||||
import { flushdb } from "@server/test/support";
|
||||
import documentImporter from "./documentImporter";
|
||||
|
||||
jest.mock("../utils/s3");
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("documentImporter", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
|
||||
@@ -144,7 +144,6 @@ export default async function documentImporter({
|
||||
user,
|
||||
ip,
|
||||
}: {
|
||||
// @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;
|
||||
file: File;
|
||||
ip: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Attachment } from "@server/models";
|
||||
import Attachment from "@server/models/Attachment";
|
||||
import {
|
||||
buildDocument,
|
||||
buildAttachment,
|
||||
@@ -10,6 +10,7 @@ import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
||||
import documentMover from "./documentMover";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("documentMover", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
@@ -33,7 +34,7 @@ describe("documentMover", () => {
|
||||
const document = await buildDocument({
|
||||
collectionId: collection.id,
|
||||
});
|
||||
await document.archive();
|
||||
await document.archive(user.id);
|
||||
const response = await documentMover({
|
||||
user,
|
||||
document,
|
||||
@@ -63,13 +64,11 @@ describe("documentMover", () => {
|
||||
index: 0,
|
||||
ip,
|
||||
});
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'documentStructure' does not exist on typ... Remove this comment to see the full error message
|
||||
expect(response.collections[0].documentStructure[0].children[0].id).toBe(
|
||||
expect(response.collections[0].documentStructure![0].children[0].id).toBe(
|
||||
newDocument.id
|
||||
);
|
||||
expect(response.collections.length).toEqual(1);
|
||||
expect(response.documents.length).toEqual(1);
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'neve... Remove this comment to see the full error message
|
||||
expect(response.documents[0].collection.id).toEqual(collection.id);
|
||||
});
|
||||
|
||||
@@ -98,22 +97,17 @@ describe("documentMover", () => {
|
||||
// check document ids where updated
|
||||
await newDocument.reload();
|
||||
expect(newDocument.collectionId).toBe(newCollection.id);
|
||||
await document.reload();
|
||||
expect(document.collectionId).toBe(newCollection.id);
|
||||
|
||||
// check collection structure updated
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'never'.
|
||||
expect(response.collections[0].id).toBe(collection.id);
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'never'.
|
||||
expect(response.collections[1].id).toBe(newCollection.id);
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'documentStructure' does not exist on typ... Remove this comment to see the full error message
|
||||
expect(response.collections[1].documentStructure[0].children[0].id).toBe(
|
||||
expect(response.collections[1].documentStructure![0].children[0].id).toBe(
|
||||
newDocument.id
|
||||
);
|
||||
expect(response.collections.length).toEqual(2);
|
||||
expect(response.documents.length).toEqual(2);
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'neve... Remove this comment to see the full error message
|
||||
|
||||
expect(response.documents[0].collection.id).toEqual(newCollection.id);
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'neve... Remove this comment to see the full error message
|
||||
expect(response.documents[1].collection.id).toEqual(newCollection.id);
|
||||
});
|
||||
|
||||
@@ -152,8 +146,8 @@ describe("documentMover", () => {
|
||||
// check new attachment was created pointint to same key
|
||||
const attachmentIds = parseAttachmentIds(newDocument.text);
|
||||
const newAttachment = await Attachment.findByPk(attachmentIds[0]);
|
||||
expect(newAttachment.documentId).toBe(newDocument.id);
|
||||
expect(newAttachment.key).toBe(attachment.key);
|
||||
expect(newAttachment?.documentId).toBe(newDocument.id);
|
||||
expect(newAttachment?.key).toBe(attachment.key);
|
||||
await document.reload();
|
||||
expect(document.collectionId).toBe(newCollection.id);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import invariant from "invariant";
|
||||
import { Transaction } from "sequelize";
|
||||
import { Document, Attachment, Collection, Pin, Event } from "@server/models";
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import {
|
||||
User,
|
||||
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,
|
||||
options?: { transaction?: Transaction }
|
||||
) {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'Document'.
|
||||
let text = document.text;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
|
||||
const documentId = document.id;
|
||||
// find any image attachments that are in this documents text
|
||||
const attachmentIds = parseAttachmentIds(text);
|
||||
@@ -18,7 +24,6 @@ async function copyAttachments(
|
||||
for (const id of attachmentIds) {
|
||||
const existing = await Attachment.findOne({
|
||||
where: {
|
||||
// @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,
|
||||
id,
|
||||
},
|
||||
@@ -29,6 +34,7 @@ async function copyAttachments(
|
||||
// then create a new attachment pointed to this doc and update the reference
|
||||
// in the text so that it gets the moved documents permissions
|
||||
if (existing && existing.documentId !== documentId) {
|
||||
// @ts-expect-error dataValues exists
|
||||
const { id, ...rest } = existing.dataValues;
|
||||
const attachment = await Attachment.create(
|
||||
{ ...rest, documentId },
|
||||
@@ -41,6 +47,21 @@ async function copyAttachments(
|
||||
return text;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
document: Document;
|
||||
collectionId: string;
|
||||
parentDocumentId?: string | null;
|
||||
index?: number;
|
||||
ip: string;
|
||||
};
|
||||
|
||||
type Result = {
|
||||
collections: Collection[];
|
||||
documents: Document[];
|
||||
collectionChanged: boolean;
|
||||
};
|
||||
|
||||
export default async function documentMover({
|
||||
user,
|
||||
document,
|
||||
@@ -49,19 +70,11 @@ export default async function documentMover({
|
||||
// convert undefined to null so parentId comparison treats them as equal
|
||||
index,
|
||||
ip,
|
||||
}: {
|
||||
// @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: any;
|
||||
collectionId: string;
|
||||
parentDocumentId?: string | null;
|
||||
index?: number;
|
||||
ip: string;
|
||||
}) {
|
||||
}: Props): Promise<Result> {
|
||||
let transaction: Transaction | undefined;
|
||||
const collectionChanged = collectionId !== document.collectionId;
|
||||
const previousCollectionId = document.collectionId;
|
||||
const result = {
|
||||
const result: Result = {
|
||||
collections: [],
|
||||
documents: [],
|
||||
collectionChanged,
|
||||
@@ -77,7 +90,6 @@ export default async function documentMover({
|
||||
document.lastModifiedById = user.id;
|
||||
document.updatedBy = user;
|
||||
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);
|
||||
} else {
|
||||
try {
|
||||
@@ -88,12 +100,13 @@ export default async function documentMover({
|
||||
transaction,
|
||||
paranoid: false,
|
||||
});
|
||||
const [
|
||||
documentJson,
|
||||
fromIndex,
|
||||
] = (await collection.removeDocumentInStructure(document, {
|
||||
|
||||
const response = await collection?.removeDocumentInStructure(document, {
|
||||
save: false,
|
||||
})) || [undefined, index];
|
||||
});
|
||||
|
||||
const documentJson = response?.[0];
|
||||
const fromIndex = response?.[1] || 0;
|
||||
|
||||
// if we're reordering from within the same parent
|
||||
// the original and destination collection are the same,
|
||||
@@ -110,7 +123,7 @@ export default async function documentMover({
|
||||
// if the collection is the same then it will get saved below, this
|
||||
// line prevents a pointless intermediate save from occurring.
|
||||
if (collectionChanged) {
|
||||
await collection.save({
|
||||
await collection?.save({
|
||||
transaction,
|
||||
});
|
||||
document.text = await copyAttachments(document, {
|
||||
@@ -124,36 +137,37 @@ export default async function documentMover({
|
||||
document.lastModifiedById = user.id;
|
||||
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
|
||||
const newCollection = collectionChanged
|
||||
? await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId, {
|
||||
transaction,
|
||||
})
|
||||
: collection;
|
||||
await newCollection.addDocumentToStructure(document, toIndex, {
|
||||
|
||||
invariant(newCollection, "collection should exist");
|
||||
|
||||
await newCollection?.addDocumentToStructure(document, toIndex, {
|
||||
documentJson,
|
||||
});
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'any' is not assignable to parame... Remove this comment to see the full error message
|
||||
result.collections.push(collection);
|
||||
|
||||
if (collection) {
|
||||
result.collections.push(collection);
|
||||
}
|
||||
|
||||
// if collection does not remain the same loop through children and change their
|
||||
// collectionId and move any attachments they may have too. This includes
|
||||
// archived children, otherwise their collection would be wrong once restored.
|
||||
if (collectionChanged) {
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'any' is not assignable to parame... Remove this comment to see the full error message
|
||||
result.collections.push(newCollection);
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'documentId' implicitly has an 'any' typ... Remove this comment to see the full error message
|
||||
const loopChildren = async (documentId) => {
|
||||
const loopChildren = async (documentId: string) => {
|
||||
const childDocuments = await Document.findAll({
|
||||
where: {
|
||||
parentDocumentId: documentId,
|
||||
},
|
||||
});
|
||||
await Promise.all(
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'child' implicitly has an 'any' type.
|
||||
childDocuments.map(async (child) => {
|
||||
await loopChildren(child.id);
|
||||
child.text = await copyAttachments(child, {
|
||||
@@ -161,8 +175,10 @@ export default async function documentMover({
|
||||
});
|
||||
child.collectionId = collectionId;
|
||||
await child.save();
|
||||
child.collection = newCollection;
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'any' is not assignable to parame... Remove this comment to see the full error message
|
||||
|
||||
if (newCollection) {
|
||||
child.collection = newCollection;
|
||||
}
|
||||
result.documents.push(child);
|
||||
})
|
||||
);
|
||||
@@ -189,13 +205,13 @@ export default async function documentMover({
|
||||
await document.save({
|
||||
transaction,
|
||||
});
|
||||
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
|
||||
|
||||
if (newCollection) {
|
||||
document.collection = newCollection;
|
||||
}
|
||||
result.documents.push(document);
|
||||
|
||||
if (transaction) {
|
||||
await transaction.commit();
|
||||
}
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
if (transaction) {
|
||||
await transaction.rollback();
|
||||
@@ -213,9 +229,7 @@ export default async function documentMover({
|
||||
teamId: document.teamId,
|
||||
data: {
|
||||
title: document.title,
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'never'.
|
||||
collectionIds: result.collections.map((c) => c.id),
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'never'.
|
||||
documentIds: result.documents.map((d) => d.id),
|
||||
},
|
||||
ip,
|
||||
|
||||
@@ -4,18 +4,8 @@ import { buildAttachment, buildDocument } from "@server/test/factories";
|
||||
import { flushdb } from "@server/test/support";
|
||||
import documentPermanentDeleter from "./documentPermanentDeleter";
|
||||
|
||||
jest.mock("aws-sdk", () => {
|
||||
const mS3 = {
|
||||
createPresignedPost: jest.fn(),
|
||||
deleteObject: jest.fn().mockReturnThis(),
|
||||
promise: jest.fn(),
|
||||
};
|
||||
return {
|
||||
S3: jest.fn(() => mS3),
|
||||
Endpoint: jest.fn(),
|
||||
};
|
||||
});
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("documentPermanentDeleter", () => {
|
||||
it("should destroy documents", async () => {
|
||||
const document = await buildDocument({
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { QueryTypes } from "sequelize";
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import Logger from "@server/logging/logger";
|
||||
import { Document, Attachment } from "@server/models";
|
||||
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
||||
import { sequelize } from "../sequelize";
|
||||
|
||||
export default async function documentPermanentDeleter(documents: Document[]) {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'deletedAt' does not exist on type 'Docum... Remove this comment to see the full error message
|
||||
const activeDocument = documents.find((doc) => !doc.deletedAt);
|
||||
|
||||
if (activeDocument) {
|
||||
throw new Error(
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
|
||||
`Cannot permanently delete ${activeDocument.id} document. Please delete it and try again.`
|
||||
);
|
||||
}
|
||||
@@ -23,16 +22,13 @@ export default async function documentPermanentDeleter(documents: Document[]) {
|
||||
`;
|
||||
|
||||
for (const document of documents) {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'Document'.
|
||||
const attachmentIds = parseAttachmentIds(document.text);
|
||||
|
||||
for (const attachmentId of attachmentIds) {
|
||||
const [{ count }] = await sequelize.query(query, {
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
|
||||
documentId: document.id,
|
||||
// @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,
|
||||
query: attachmentId,
|
||||
},
|
||||
@@ -41,7 +37,6 @@ export default async function documentPermanentDeleter(documents: Document[]) {
|
||||
if (parseInt(count) === 0) {
|
||||
const attachment = await Attachment.findOne({
|
||||
where: {
|
||||
// @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,
|
||||
id: attachmentId,
|
||||
},
|
||||
@@ -59,7 +54,6 @@ export default async function documentPermanentDeleter(documents: Document[]) {
|
||||
|
||||
return Document.scope("withUnpublished").destroy({
|
||||
where: {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
|
||||
id: documents.map((document) => document.id),
|
||||
},
|
||||
force: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import invariant from "invariant";
|
||||
import { uniq } from "lodash";
|
||||
import { Node } from "prosemirror-model";
|
||||
import { schema, serializer } from "rich-markdown-editor";
|
||||
@@ -15,6 +16,8 @@ export default async function documentUpdater({
|
||||
userId?: string;
|
||||
}) {
|
||||
const document = await Document.findByPk(documentId);
|
||||
invariant(document, "document not found");
|
||||
|
||||
const state = Y.encodeStateAsUpdate(ydoc);
|
||||
const node = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
|
||||
const text = serializer.serialize(node, undefined);
|
||||
@@ -30,6 +33,7 @@ export default async function documentUpdater({
|
||||
const pudIds = Array.from(pud.clients.values());
|
||||
const existingIds = document.collaboratorIds;
|
||||
const collaboratorIds = uniq([...pudIds, ...existingIds]);
|
||||
|
||||
await Document.scope("withUnpublished").update(
|
||||
{
|
||||
text,
|
||||
|
||||
@@ -3,18 +3,8 @@ import { buildAdmin, buildFileOperation } from "@server/test/factories";
|
||||
import { flushdb } from "@server/test/support";
|
||||
import fileOperationDeleter from "./fileOperationDeleter";
|
||||
|
||||
jest.mock("aws-sdk", () => {
|
||||
const mS3 = {
|
||||
createPresignedPost: jest.fn(),
|
||||
deleteObject: jest.fn().mockReturnThis(),
|
||||
promise: jest.fn(),
|
||||
};
|
||||
return {
|
||||
S3: jest.fn(() => mS3),
|
||||
Endpoint: jest.fn(),
|
||||
};
|
||||
});
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("fileOperationDeleter", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import { FileOperation, Event, User } from "@server/models";
|
||||
import { sequelize } from "../sequelize";
|
||||
|
||||
export default async function fileOperationDeleter(
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'FileOperation' refers to a value, but is being us... Remove this comment to see the full error message
|
||||
fileOp: FileOperation,
|
||||
// @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,
|
||||
ip: string
|
||||
) {
|
||||
@@ -19,6 +17,7 @@ export default async function fileOperationDeleter(
|
||||
name: "fileOperations.delete",
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
// @ts-expect-error dataValues does exist
|
||||
data: fileOp.dataValues,
|
||||
ip,
|
||||
},
|
||||
|
||||
@@ -25,8 +25,8 @@ describe("pinCreator", () => {
|
||||
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);
|
||||
expect(event!.name).toEqual("pins.create");
|
||||
expect(event!.modelId).toEqual(pin.id);
|
||||
});
|
||||
|
||||
it("should create pin to collection", async () => {
|
||||
@@ -48,8 +48,8 @@ describe("pinCreator", () => {
|
||||
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);
|
||||
expect(event!.name).toEqual("pins.create");
|
||||
expect(event!.modelId).toEqual(pin.id);
|
||||
expect(event!.collectionId).toEqual(pin.collectionId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { Sequelize, Op, WhereOptions } from "sequelize";
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import { Pin, Event } from "@server/models";
|
||||
import { sequelize, Op } from "@server/sequelize";
|
||||
import { Pin, User, Event } from "@server/models";
|
||||
|
||||
const MAX_PINS = 8;
|
||||
|
||||
type Props = {
|
||||
/** The user creating the pin */
|
||||
user: any;
|
||||
user: User;
|
||||
/** 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 */
|
||||
@@ -31,11 +32,11 @@ export default async function pinCreator({
|
||||
collectionId,
|
||||
ip,
|
||||
...rest
|
||||
}: Props): Promise<any> {
|
||||
}: Props): Promise<Pin> {
|
||||
let { index } = rest;
|
||||
const where = {
|
||||
const where: WhereOptions<Pin> = {
|
||||
teamId: user.teamId,
|
||||
...(collectionId ? { collectionId } : { collectionId: { [Op.eq]: null } }),
|
||||
...(collectionId ? { collectionId } : { collectionId: { [Op.is]: null } }),
|
||||
};
|
||||
|
||||
const count = await Pin.count({ where });
|
||||
@@ -51,7 +52,7 @@ export default async function pinCreator({
|
||||
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'),
|
||||
Sequelize.literal('"pin"."index" collate "C" DESC'),
|
||||
["updatedAt", "ASC"],
|
||||
],
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("pinCreator", () => {
|
||||
expect(count).toEqual(0);
|
||||
|
||||
const event = await Event.findOne();
|
||||
expect(event.name).toEqual("pins.delete");
|
||||
expect(event.modelId).toEqual(pin.id);
|
||||
expect(event!.name).toEqual("pins.delete");
|
||||
expect(event!.modelId).toEqual(pin.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { Event } from "@server/models";
|
||||
import { sequelize } from "@server/sequelize";
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import { Event, Pin, User } from "@server/models";
|
||||
|
||||
type Props = {
|
||||
/** The user destroying the pin */
|
||||
user: any;
|
||||
user: User;
|
||||
/** The pin to destroy */
|
||||
pin: any;
|
||||
pin: Pin;
|
||||
/** The IP address of the user creating the pin */
|
||||
ip: string;
|
||||
/** Optional existing transaction */
|
||||
@@ -25,7 +25,7 @@ export default async function pinDestroyer({
|
||||
pin,
|
||||
ip,
|
||||
transaction: t,
|
||||
}: Props): Promise<any> {
|
||||
}: Props): Promise<Pin> {
|
||||
const transaction = t || (await sequelize.transaction());
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Event } from "@server/models";
|
||||
import { sequelize } from "@server/sequelize";
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import { Event, Pin, User } from "@server/models";
|
||||
|
||||
type Props = {
|
||||
/** The user updating the pin */
|
||||
user: any;
|
||||
user: User;
|
||||
/** The existing pin */
|
||||
pin: any;
|
||||
pin: Pin;
|
||||
/** The index to pin the document at */
|
||||
index?: string;
|
||||
index: string;
|
||||
/** The IP address of the user creating the pin */
|
||||
ip: string;
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export default async function pinUpdater({
|
||||
pin,
|
||||
index,
|
||||
ip,
|
||||
}: Props): Promise<any> {
|
||||
}: Props): Promise<Pin> {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { flushdb } from "@server/test/support";
|
||||
import revisionCreator from "./revisionCreator";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("revisionCreator", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
@@ -21,8 +22,8 @@ describe("revisionCreator", () => {
|
||||
const event = await Event.findOne();
|
||||
expect(revision.documentId).toEqual(document.id);
|
||||
expect(revision.userId).toEqual(user.id);
|
||||
expect(event.name).toEqual("revisions.create");
|
||||
expect(event.modelId).toEqual(revision.id);
|
||||
expect(event.createdAt).toEqual(document.updatedAt);
|
||||
expect(event!.name).toEqual("revisions.create");
|
||||
expect(event!.modelId).toEqual(revision.id);
|
||||
expect(event!.createdAt).toEqual(document.updatedAt);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import { Document, User, Event, Revision } from "@server/models";
|
||||
import { sequelize } from "../sequelize";
|
||||
|
||||
export default async function revisionCreator({
|
||||
document,
|
||||
@@ -7,7 +7,6 @@ export default async function revisionCreator({
|
||||
ip,
|
||||
}: {
|
||||
document: Document;
|
||||
// @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;
|
||||
ip?: string;
|
||||
}) {
|
||||
@@ -21,13 +20,10 @@ export default async function revisionCreator({
|
||||
await Event.create(
|
||||
{
|
||||
name: "revisions.create",
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
|
||||
documentId: document.id,
|
||||
modelId: revision.id,
|
||||
// @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,
|
||||
actorId: user.id,
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedAt' does not exist on type 'Docum... Remove this comment to see the full error message
|
||||
createdAt: document.updatedAt,
|
||||
ip: ip || user.lastActiveIp,
|
||||
},
|
||||
|
||||
@@ -2,18 +2,8 @@ import { buildTeam } from "@server/test/factories";
|
||||
import { flushdb } from "@server/test/support";
|
||||
import teamCreator from "./teamCreator";
|
||||
|
||||
jest.mock("aws-sdk", () => {
|
||||
const mS3 = {
|
||||
createPresignedPost: jest.fn(),
|
||||
putObject: jest.fn().mockReturnThis(),
|
||||
promise: jest.fn(),
|
||||
};
|
||||
return {
|
||||
S3: jest.fn(() => mS3),
|
||||
Endpoint: jest.fn(),
|
||||
};
|
||||
});
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("teamCreator", () => {
|
||||
it("should create team and authentication provider", async () => {
|
||||
const result = await teamCreator({
|
||||
@@ -73,7 +63,7 @@ describe("teamCreator", () => {
|
||||
expect(authenticationProvider.name).toEqual("google");
|
||||
expect(authenticationProvider.providerId).toEqual("allowed-domain.com");
|
||||
expect(isNewTeam).toEqual(false);
|
||||
const providers = await team.getAuthenticationProviders();
|
||||
const providers = await team.$get("authenticationProviders");
|
||||
expect(providers.length).toEqual(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import invariant from "invariant";
|
||||
import Logger from "@server/logging/logger";
|
||||
import { Team, AuthenticationProvider } from "@server/models";
|
||||
import { getAllowedDomains } from "@server/utils/authentication";
|
||||
import { generateAvatarUrl } from "@server/utils/avatars";
|
||||
import { MaximumTeamsError } from "../errors";
|
||||
import { sequelize } from "../sequelize";
|
||||
|
||||
type TeamCreatorResult = {
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Team' refers to a value, but is being used as a t... Remove this comment to see the full error message
|
||||
team: Team;
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'AuthenticationProvider' refers to a value, but is... Remove this comment to see the full error message
|
||||
authenticationProvider: AuthenticationProvider;
|
||||
isNewTeam: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
domain?: string;
|
||||
subdomain: string;
|
||||
avatarUrl?: string | null;
|
||||
authenticationProvider: {
|
||||
name: string;
|
||||
providerId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function teamCreator({
|
||||
name,
|
||||
domain,
|
||||
subdomain,
|
||||
avatarUrl,
|
||||
authenticationProvider,
|
||||
}: {
|
||||
name: string;
|
||||
domain?: string;
|
||||
subdomain: string;
|
||||
avatarUrl?: string;
|
||||
authenticationProvider: {
|
||||
name: string;
|
||||
providerId: string;
|
||||
};
|
||||
}): Promise<TeamCreatorResult> {
|
||||
}: Props): Promise<TeamCreatorResult> {
|
||||
let authP = await AuthenticationProvider.findOne({
|
||||
where: authenticationProvider,
|
||||
include: [
|
||||
@@ -61,7 +61,12 @@ export default async function teamCreator({
|
||||
// authentication provider to the existing team
|
||||
if (teamCount === 1 && domain && getAllowedDomains().includes(domain)) {
|
||||
const team = await Team.findOne();
|
||||
authP = await team.createAuthenticationProvider(authenticationProvider);
|
||||
invariant(team, "Team should exist");
|
||||
|
||||
authP = await team.$create<AuthenticationProvider>(
|
||||
"authenticationProvider",
|
||||
authenticationProvider
|
||||
);
|
||||
return {
|
||||
authenticationProvider: authP,
|
||||
team,
|
||||
@@ -84,7 +89,7 @@ export default async function teamCreator({
|
||||
});
|
||||
}
|
||||
|
||||
const transaction = await sequelize.transaction();
|
||||
const transaction = await Team.sequelize!.transaction();
|
||||
let team;
|
||||
|
||||
try {
|
||||
|
||||
@@ -9,18 +9,8 @@ import {
|
||||
import { flushdb } from "@server/test/support";
|
||||
import teamPermanentDeleter from "./teamPermanentDeleter";
|
||||
|
||||
jest.mock("aws-sdk", () => {
|
||||
const mS3 = {
|
||||
createPresignedPost: jest.fn(),
|
||||
deleteObject: jest.fn().mockReturnThis(),
|
||||
promise: jest.fn(),
|
||||
};
|
||||
return {
|
||||
S3: jest.fn(() => mS3),
|
||||
Endpoint: jest.fn(),
|
||||
};
|
||||
});
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("teamPermanentDeleter", () => {
|
||||
it("should destroy related data", async () => {
|
||||
const team = await buildTeam({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import Logger from "@server/logging/logger";
|
||||
import {
|
||||
ApiKey,
|
||||
@@ -17,9 +18,7 @@ import {
|
||||
SearchQuery,
|
||||
Share,
|
||||
} from "@server/models";
|
||||
import { sequelize } from "../sequelize";
|
||||
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Team' refers to a value, but is being used as a t... Remove this comment to see the full error message
|
||||
export default async function teamPermanentDeleter(team: Team) {
|
||||
if (!team.deletedAt) {
|
||||
throw new Error(
|
||||
@@ -45,16 +44,14 @@ export default async function teamPermanentDeleter(team: Team) {
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
},
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'attachments' implicitly has an 'any' ty... Remove this comment to see the full error message
|
||||
async (attachments, options) => {
|
||||
Logger.info(
|
||||
"commands",
|
||||
`Deleting attachments ${options.offset} – ${
|
||||
options.offset + options.limit
|
||||
(options.offset || 0) + (options?.limit || 0)
|
||||
}…`
|
||||
);
|
||||
await Promise.all(
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'attachment' implicitly has an 'any' typ... Remove this comment to see the full error message
|
||||
attachments.map((attachment) =>
|
||||
attachment.destroy({
|
||||
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'transaction' implicitly has an 'any' typ... Remove this comment to see the full error message
|
||||
@@ -74,9 +71,7 @@ export default async function teamPermanentDeleter(team: Team) {
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
},
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'users' implicitly has an 'any' type.
|
||||
async (users) => {
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'user' implicitly has an 'any' type.
|
||||
const userIds = users.map((user) => user.id);
|
||||
await UserAuthentication.destroy({
|
||||
where: {
|
||||
|
||||
@@ -3,12 +3,13 @@ import { flushdb } from "@server/test/support";
|
||||
import userCreator from "./userCreator";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("userCreator", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should update exising user and authentication", async () => {
|
||||
const existing = await buildUser();
|
||||
const authentications = await existing.getAuthentications();
|
||||
const authentications = await existing.$get("authentications");
|
||||
const existingAuth = authentications[0];
|
||||
const newEmail = "test@example.com";
|
||||
const newUsername = "tname";
|
||||
@@ -37,7 +38,7 @@ describe("userCreator", () => {
|
||||
|
||||
it("should create user with deleted user matching providerId", async () => {
|
||||
const existing = await buildUser();
|
||||
const authentications = await existing.getAuthentications();
|
||||
const authentications = await existing.$get("authentications");
|
||||
const existingAuth = authentications[0];
|
||||
const newEmail = "test@example.com";
|
||||
await existing.destroy();
|
||||
@@ -63,7 +64,7 @@ describe("userCreator", () => {
|
||||
|
||||
it("should handle duplicate providerId for different iDP", async () => {
|
||||
const existing = await buildUser();
|
||||
const authentications = await existing.getAuthentications();
|
||||
const authentications = await existing.$get("authentications");
|
||||
const existingAuth = authentications[0];
|
||||
let error;
|
||||
|
||||
@@ -89,7 +90,7 @@ describe("userCreator", () => {
|
||||
|
||||
it("should create a new user", async () => {
|
||||
const team = await buildTeam();
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
const authenticationProviders = await team.$get("authenticationProviders");
|
||||
const authenticationProvider = authenticationProviders[0];
|
||||
const result = await userCreator({
|
||||
name: "Test Name",
|
||||
@@ -119,7 +120,7 @@ describe("userCreator", () => {
|
||||
const team = await buildTeam({
|
||||
defaultUserRole: "viewer",
|
||||
});
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
const authenticationProviders = await team.$get("authenticationProviders");
|
||||
const authenticationProvider = authenticationProviders[0];
|
||||
const result = await userCreator({
|
||||
name: "Test Name",
|
||||
@@ -143,7 +144,7 @@ describe("userCreator", () => {
|
||||
const team = await buildTeam({
|
||||
defaultUserRole: "viewer",
|
||||
});
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
const authenticationProviders = await team.$get("authenticationProviders");
|
||||
const authenticationProvider = authenticationProviders[0];
|
||||
const result = await userCreator({
|
||||
name: "Test Name",
|
||||
@@ -187,11 +188,11 @@ describe("userCreator", () => {
|
||||
const invite = await buildInvite({
|
||||
teamId: team.id,
|
||||
});
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
const authenticationProviders = await team.$get("authenticationProviders");
|
||||
const authenticationProvider = authenticationProviders[0];
|
||||
const result = await userCreator({
|
||||
name: invite.name,
|
||||
email: invite.email,
|
||||
email: invite.email!,
|
||||
teamId: invite.teamId,
|
||||
ip,
|
||||
authentication: {
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { Op } from "sequelize";
|
||||
import { Event, Team, User, UserAuthentication } from "@server/models";
|
||||
import { sequelize } from "../sequelize";
|
||||
|
||||
type UserCreatorResult = {
|
||||
// @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;
|
||||
isNewUser: boolean;
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'UserAuthentication' refers to a value, but is bei... Remove this comment to see the full error message
|
||||
authentication: UserAuthentication;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
isAdmin?: boolean;
|
||||
avatarUrl?: string | null;
|
||||
teamId: string;
|
||||
ip: string;
|
||||
authentication: {
|
||||
authenticationProviderId: string;
|
||||
providerId: string;
|
||||
scopes: string[];
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function userCreator({
|
||||
name,
|
||||
email,
|
||||
@@ -19,22 +33,7 @@ export default async function userCreator({
|
||||
teamId,
|
||||
authentication,
|
||||
ip,
|
||||
}: {
|
||||
name: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
isAdmin?: boolean;
|
||||
avatarUrl?: string;
|
||||
teamId: string;
|
||||
ip: string;
|
||||
authentication: {
|
||||
authenticationProviderId: string;
|
||||
providerId: string;
|
||||
scopes: string[];
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
}): Promise<UserCreatorResult> {
|
||||
}: Props): Promise<UserCreatorResult> {
|
||||
const { authenticationProviderId, providerId, ...rest } = authentication;
|
||||
const auth = await UserAuthentication.findOne({
|
||||
where: {
|
||||
@@ -90,7 +89,7 @@ export default async function userCreator({
|
||||
email,
|
||||
teamId,
|
||||
lastActiveAt: {
|
||||
[Op.eq]: null,
|
||||
[Op.is]: null,
|
||||
},
|
||||
},
|
||||
include: [
|
||||
@@ -105,7 +104,7 @@ export default async function userCreator({
|
||||
// We have an existing invite for his user, so we need to update it with our
|
||||
// new details and link up the authentication method
|
||||
if (invite && !invite.authentications.length) {
|
||||
const transaction = await sequelize.transaction();
|
||||
const transaction = await User.sequelize!.transaction();
|
||||
let auth;
|
||||
|
||||
try {
|
||||
@@ -118,9 +117,13 @@ export default async function userCreator({
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
auth = await invite.createAuthentication(authentication, {
|
||||
transaction,
|
||||
});
|
||||
auth = await invite.$create<UserAuthentication>(
|
||||
"authentication",
|
||||
authentication,
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
@@ -135,13 +138,15 @@ export default async function userCreator({
|
||||
}
|
||||
|
||||
// No auth, no user – this is an entirely new sign in.
|
||||
const transaction = await sequelize.transaction();
|
||||
const transaction = await User.sequelize!.transaction();
|
||||
|
||||
try {
|
||||
const { defaultUserRole } = await Team.findByPk(teamId, {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
attributes: ["defaultUserRole"],
|
||||
transaction,
|
||||
});
|
||||
const defaultUserRole = team?.defaultUserRole;
|
||||
|
||||
const user = await User.create(
|
||||
{
|
||||
name,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { flushdb } from "@server/test/support";
|
||||
import userDestroyer from "./userDestroyer";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("userDestroyer", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Op } from "sequelize";
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import { Event, User } from "@server/models";
|
||||
import { ValidationError } from "../errors";
|
||||
import { Op, sequelize } from "../sequelize";
|
||||
|
||||
export default async function userDestroyer({
|
||||
user,
|
||||
actor,
|
||||
ip,
|
||||
}: {
|
||||
// @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;
|
||||
// @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
|
||||
actor: User;
|
||||
ip: string;
|
||||
}) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { flushdb } from "@server/test/support";
|
||||
import userInviter from "./userInviter";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("userInviter", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
@@ -10,8 +11,8 @@ describe("userInviter", () => {
|
||||
const user = await buildUser();
|
||||
const response = await userInviter({
|
||||
invites: [
|
||||
// @ts-expect-error ts-migrate(2741) FIXME: Property 'role' is missing in type '{ email: strin... Remove this comment to see the full error message
|
||||
{
|
||||
role: "member",
|
||||
email: "test@example.com",
|
||||
name: "Test",
|
||||
},
|
||||
@@ -26,8 +27,8 @@ describe("userInviter", () => {
|
||||
const user = await buildUser();
|
||||
const response = await userInviter({
|
||||
invites: [
|
||||
// @ts-expect-error ts-migrate(2741) FIXME: Property 'role' is missing in type '{ email: strin... Remove this comment to see the full error message
|
||||
{
|
||||
role: "member",
|
||||
email: " ",
|
||||
name: "Test",
|
||||
},
|
||||
@@ -42,8 +43,8 @@ describe("userInviter", () => {
|
||||
const user = await buildUser();
|
||||
const response = await userInviter({
|
||||
invites: [
|
||||
// @ts-expect-error ts-migrate(2741) FIXME: Property 'role' is missing in type '{ email: strin... Remove this comment to see the full error message
|
||||
{
|
||||
role: "member",
|
||||
email: "notanemail",
|
||||
name: "Test",
|
||||
},
|
||||
@@ -58,13 +59,13 @@ describe("userInviter", () => {
|
||||
const user = await buildUser();
|
||||
const response = await userInviter({
|
||||
invites: [
|
||||
// @ts-expect-error ts-migrate(2741) FIXME: Property 'role' is missing in type '{ email: strin... Remove this comment to see the full error message
|
||||
{
|
||||
role: "member",
|
||||
email: "the@same.com",
|
||||
name: "Test",
|
||||
},
|
||||
// @ts-expect-error ts-migrate(2741) FIXME: Property 'role' is missing in type '{ email: strin... Remove this comment to see the full error message
|
||||
{
|
||||
role: "member",
|
||||
email: "the@SAME.COM",
|
||||
name: "Test",
|
||||
},
|
||||
@@ -79,9 +80,9 @@ describe("userInviter", () => {
|
||||
const user = await buildUser();
|
||||
const response = await userInviter({
|
||||
invites: [
|
||||
// @ts-expect-error ts-migrate(2741) FIXME: Property 'role' is missing in type '{ email: any; ... Remove this comment to see the full error message
|
||||
{
|
||||
email: user.email,
|
||||
role: "member",
|
||||
email: user.email!,
|
||||
name: user.name,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import invariant from "invariant";
|
||||
import { uniqBy } from "lodash";
|
||||
import { Role } from "@shared/types";
|
||||
import mailer from "@server/mailer";
|
||||
import { User, Event, Team } from "@server/models";
|
||||
import mailer from "../mailer";
|
||||
|
||||
type Invite = {
|
||||
name: string;
|
||||
@@ -14,16 +15,16 @@ export default async function userInviter({
|
||||
invites,
|
||||
ip,
|
||||
}: {
|
||||
// @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;
|
||||
invites: Invite[];
|
||||
ip: string;
|
||||
}): Promise<{
|
||||
sent: Invite[];
|
||||
// @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
|
||||
users: User[];
|
||||
}> {
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
invariant(team, "team not found");
|
||||
|
||||
// filter out empties and obvious non-emails
|
||||
const compactedInvites = invites.filter(
|
||||
(invite) => !!invite.email.trim() && invite.email.match("@")
|
||||
@@ -44,7 +45,6 @@ export default async function userInviter({
|
||||
email: emails,
|
||||
},
|
||||
});
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'user' implicitly has an 'any' type.
|
||||
const existingEmails = existingUsers.map((user) => user.email);
|
||||
const filteredInvites = normalizedInvites.filter(
|
||||
(invite) => !existingEmails.includes(invite.email)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { GroupUser } from "@server/models";
|
||||
import GroupUser from "@server/models/GroupUser";
|
||||
import { buildGroup, buildAdmin, buildUser } from "@server/test/factories";
|
||||
import { flushdb } from "@server/test/support";
|
||||
import userSuspender from "./userSuspender";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("userSuspender", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
@@ -46,7 +47,7 @@ describe("userSuspender", () => {
|
||||
const group = await buildGroup({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
await group.addUser(user, {
|
||||
await group.$add("user", user, {
|
||||
through: {
|
||||
createdById: user.id,
|
||||
},
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import { User, Event, GroupUser } from "@server/models";
|
||||
import { ValidationError } from "../errors";
|
||||
import { sequelize } from "../sequelize";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
actorId: string;
|
||||
ip: string;
|
||||
};
|
||||
|
||||
export default async function userSuspender({
|
||||
user,
|
||||
actorId,
|
||||
ip,
|
||||
}: {
|
||||
// @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;
|
||||
actorId: string;
|
||||
ip: string;
|
||||
}): Promise<void> {
|
||||
}: Props): Promise<void> {
|
||||
if (user.id === actorId) {
|
||||
throw ValidationError("Unable to suspend the current user");
|
||||
}
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import Sequelize from "sequelize";
|
||||
import EncryptedField from "sequelize-encrypted";
|
||||
import Logger from "./logging/logger";
|
||||
import { Sequelize } from "sequelize-typescript";
|
||||
import Logger from "../logging/logger";
|
||||
import * as models from "../models";
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const isSSLDisabled = process.env.PGSSLMODE === "disable";
|
||||
|
||||
export const encryptedFields = () =>
|
||||
EncryptedField(Sequelize, process.env.SECRET_KEY);
|
||||
|
||||
export const DataTypes = Sequelize;
|
||||
|
||||
export const Op = Sequelize.Op;
|
||||
|
||||
// @ts-expect-error ts-migrate(2351) FIXME: This expression is not constructable.
|
||||
export const sequelize = new Sequelize(
|
||||
process.env.DATABASE_URL || process.env.DATABASE_CONNECTION_POOL_URL,
|
||||
process.env.DATABASE_URL || process.env.DATABASE_CONNECTION_POOL_URL || "",
|
||||
{
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'msg' implicitly has an 'any' type.
|
||||
logging: (msg) => Logger.debug("database", msg),
|
||||
typeValidation: true,
|
||||
dialectOptions: {
|
||||
@@ -28,5 +19,6 @@ export const sequelize = new Sequelize(
|
||||
}
|
||||
: false,
|
||||
},
|
||||
models: Object.values(models),
|
||||
}
|
||||
);
|
||||
9
server/database/vaults.ts
Normal file
9
server/database/vaults.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import SequelizeEncrypted from "sequelize-encrypted";
|
||||
import { Sequelize } from "sequelize-typescript";
|
||||
|
||||
/**
|
||||
* Encrypted field storage, use via the Encrypted decorator, not directly.
|
||||
*/
|
||||
export default function vaults() {
|
||||
return SequelizeEncrypted(Sequelize, process.env.SECRET_KEY);
|
||||
}
|
||||
@@ -9,9 +9,7 @@ import Header from "./components/Header";
|
||||
import Heading from "./components/Heading";
|
||||
|
||||
export type Props = {
|
||||
// @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
|
||||
actor: 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
|
||||
collection: Collection;
|
||||
eventName: string;
|
||||
unsubscribeUrl: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { User, Team, Collection } from "@server/models";
|
||||
import { Document, User, Team, Collection } from "@server/models";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
import EmailTemplate from "./components/EmailLayout";
|
||||
@@ -9,12 +9,9 @@ import Header from "./components/Header";
|
||||
import Heading from "./components/Heading";
|
||||
|
||||
export type Props = {
|
||||
// @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
|
||||
actor: User;
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Team' refers to a value, but is being used as a t... Remove this comment to see the full error message
|
||||
team: Team;
|
||||
document: any;
|
||||
// @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
|
||||
document: Document;
|
||||
collection: Collection;
|
||||
eventName: string;
|
||||
unsubscribeUrl: string;
|
||||
|
||||
@@ -28,7 +28,11 @@ export function AdminRequiredError(
|
||||
});
|
||||
}
|
||||
|
||||
export function UserSuspendedError({ adminEmail }: { adminEmail: string }) {
|
||||
export function UserSuspendedError({
|
||||
adminEmail,
|
||||
}: {
|
||||
adminEmail: string | undefined;
|
||||
}) {
|
||||
return httpErrors(403, "Your access has been suspended by the team admin", {
|
||||
id: "user_suspended",
|
||||
errorData: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import env from "./env"; // eslint-disable-line import/order
|
||||
/* eslint-disable import/order */
|
||||
import env from "./env";
|
||||
|
||||
import "./tracing"; // must come before importing any instrumented module
|
||||
|
||||
@@ -10,6 +11,7 @@ import logger from "koa-logger";
|
||||
import onerror from "koa-onerror";
|
||||
import Router from "koa-router";
|
||||
import { uniq } from "lodash";
|
||||
import { AddressInfo } from "net";
|
||||
import stoppable from "stoppable";
|
||||
import throng from "throng";
|
||||
import Logger from "./logging/logger";
|
||||
@@ -104,8 +106,10 @@ async function start(id: number, disconnect: () => void) {
|
||||
server.on("listening", () => {
|
||||
const address = server.address();
|
||||
|
||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
||||
Logger.info("lifecycle", `Listening on http://localhost:${address.port}`);
|
||||
Logger.info(
|
||||
"lifecycle",
|
||||
`Listening on http://localhost:${(address as AddressInfo).port}`
|
||||
);
|
||||
});
|
||||
server.listen(normalizedPortFlag || env.PORT || "3000");
|
||||
process.once("SIGTERM", shutdown);
|
||||
|
||||
@@ -2,15 +2,14 @@ import mailer from "./mailer";
|
||||
|
||||
describe("Mailer", () => {
|
||||
const fakeMailer = mailer;
|
||||
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'sendMailOutput' implicitly has type 'any... Remove this comment to see the full error message
|
||||
let sendMailOutput;
|
||||
let sendMailOutput: any;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.URL = "http://localhost:3000";
|
||||
process.env.SMTP_FROM_EMAIL = "hello@example.com";
|
||||
jest.resetModules();
|
||||
fakeMailer.transporter = {
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'output' implicitly has an 'any' type.
|
||||
sendMail: (output) => (sendMailOutput = output),
|
||||
sendMail: (output: any) => (sendMailOutput = output),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -19,7 +18,6 @@ describe("Mailer", () => {
|
||||
to: "user@example.com",
|
||||
teamUrl: "http://example.com",
|
||||
});
|
||||
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'sendMailOutput' implicitly has an 'any' ... Remove this comment to see the full error message
|
||||
expect(sendMailOutput).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,8 +251,8 @@ export class Mailer {
|
||||
});
|
||||
};
|
||||
|
||||
sendTemplate = async (type: EmailTypes, opts: Record<string, any> = {}) => {
|
||||
await emailsQueue.add(
|
||||
sendTemplate = (type: EmailTypes, opts: Record<string, any> = {}) => {
|
||||
return emailsQueue.add(
|
||||
{
|
||||
type,
|
||||
opts,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import randomstring from "randomstring";
|
||||
import { ApiKey } from "@server/models";
|
||||
import ApiKey from "@server/models/ApiKey";
|
||||
import { buildUser, buildTeam } from "@server/test/factories";
|
||||
import { flushdb } from "@server/test/support";
|
||||
import auth from "./authentication";
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function auth(
|
||||
paranoid: false,
|
||||
});
|
||||
throw UserSuspendedError({
|
||||
adminEmail: suspendingAdmin.email,
|
||||
adminEmail: suspendingAdmin?.email || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Context, Next } from "koa";
|
||||
import { snakeCase } from "lodash";
|
||||
import Sequelize from "sequelize";
|
||||
import { ValidationError } from "sequelize";
|
||||
|
||||
export default function errorHandling() {
|
||||
return async function errorHandlingMiddleware(ctx: Context, next: Next) {
|
||||
@@ -11,7 +11,7 @@ export default function errorHandling() {
|
||||
let message = err.message || err.name;
|
||||
let error;
|
||||
|
||||
if (err instanceof Sequelize.ValidationError) {
|
||||
if (err instanceof ValidationError) {
|
||||
// super basic form error handling
|
||||
ctx.status = 400;
|
||||
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
import randomstring from "randomstring";
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
import {
|
||||
Column,
|
||||
Table,
|
||||
Unique,
|
||||
BeforeValidate,
|
||||
BelongsTo,
|
||||
ForeignKey,
|
||||
} from "sequelize-typescript";
|
||||
import User from "./User";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const ApiKey = sequelize.define(
|
||||
"apiKey",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
secret: {
|
||||
type: DataTypes.STRING,
|
||||
unique: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
paranoid: true,
|
||||
hooks: {
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'key' implicitly has an 'any' type.
|
||||
beforeValidate: (key) => {
|
||||
key.secret = randomstring.generate(38);
|
||||
},
|
||||
},
|
||||
@Table({ tableName: "apiKeys", modelName: "apiKey" })
|
||||
@Fix
|
||||
class ApiKey extends ParanoidModel {
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
@Unique
|
||||
@Column
|
||||
secret: string;
|
||||
|
||||
// hooks
|
||||
|
||||
@BeforeValidate
|
||||
static async generateSecret(model: ApiKey) {
|
||||
if (!model.secret) {
|
||||
model.secret = randomstring.generate(38);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
ApiKey.associate = (models) => {
|
||||
ApiKey.belongsTo(models.User, {
|
||||
as: "user",
|
||||
foreignKey: "userId",
|
||||
});
|
||||
};
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export default ApiKey;
|
||||
|
||||
@@ -1,88 +1,107 @@
|
||||
import path from "path";
|
||||
import { FindOptions } from "sequelize";
|
||||
import {
|
||||
BeforeDestroy,
|
||||
BelongsTo,
|
||||
Column,
|
||||
Default,
|
||||
ForeignKey,
|
||||
IsIn,
|
||||
Table,
|
||||
DataType,
|
||||
} from "sequelize-typescript";
|
||||
import { deleteFromS3, getFileByKey } from "@server/utils/s3";
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
import Document from "./Document";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import BaseModel from "./base/BaseModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const Attachment = sequelize.define(
|
||||
"attachment",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
key: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
url: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
contentType: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
size: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
},
|
||||
acl: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: "public-read",
|
||||
validate: {
|
||||
isIn: [["private", "public-read"]],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
getterMethods: {
|
||||
name: function () {
|
||||
return path.parse(this.key).base;
|
||||
},
|
||||
redirectUrl: function () {
|
||||
return `/api/attachments.redirect?id=${this.id}`;
|
||||
},
|
||||
isPrivate: function () {
|
||||
return this.acl === "private";
|
||||
},
|
||||
buffer: function () {
|
||||
return getFileByKey(this.key);
|
||||
},
|
||||
},
|
||||
@Table({ tableName: "attachments", modelName: "attachment" })
|
||||
@Fix
|
||||
class Attachment extends BaseModel {
|
||||
@Column
|
||||
key: string;
|
||||
|
||||
@Column
|
||||
url: string;
|
||||
|
||||
@Column
|
||||
contentType: string;
|
||||
|
||||
@Column(DataType.BIGINT)
|
||||
size: number;
|
||||
|
||||
@Default("public-read")
|
||||
@IsIn([["private", "public-read"]])
|
||||
@Column
|
||||
acl: string;
|
||||
|
||||
// getters
|
||||
|
||||
get name() {
|
||||
return path.parse(this.key).base;
|
||||
}
|
||||
);
|
||||
|
||||
Attachment.findAllInBatches = async (
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'query' implicitly has an 'any' type.
|
||||
query,
|
||||
callback: (
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Attachment' refers to a value, but is being used ... Remove this comment to see the full error message
|
||||
attachments: Array<Attachment>,
|
||||
query: Record<string, any>
|
||||
) => Promise<void>
|
||||
) => {
|
||||
if (!query.offset) query.offset = 0;
|
||||
if (!query.limit) query.limit = 10;
|
||||
let results;
|
||||
get redirectUrl() {
|
||||
return `/api/attachments.redirect?id=${this.id}`;
|
||||
}
|
||||
|
||||
do {
|
||||
results = await Attachment.findAll(query);
|
||||
await callback(results, query);
|
||||
query.offset += query.limit;
|
||||
} while (results.length >= query.limit);
|
||||
};
|
||||
get isPrivate() {
|
||||
return this.acl === "private";
|
||||
}
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'model' implicitly has an 'any' type.
|
||||
Attachment.beforeDestroy(async (model) => {
|
||||
await deleteFromS3(model.key);
|
||||
});
|
||||
get buffer() {
|
||||
return getFileByKey(this.key);
|
||||
}
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
Attachment.associate = (models) => {
|
||||
Attachment.belongsTo(models.Team);
|
||||
Attachment.belongsTo(models.Document);
|
||||
Attachment.belongsTo(models.User);
|
||||
};
|
||||
// hooks
|
||||
|
||||
@BeforeDestroy
|
||||
static async deleteAttachmentFromS3(model: Attachment) {
|
||||
await deleteFromS3(model.key);
|
||||
}
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => Team, "teamId")
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
|
||||
@BelongsTo(() => Document, "documentId")
|
||||
document: Document;
|
||||
|
||||
@ForeignKey(() => Document)
|
||||
@Column(DataType.UUID)
|
||||
documentId: string | null;
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
static async findAllInBatches(
|
||||
query: FindOptions<Attachment>,
|
||||
callback: (
|
||||
attachments: Array<Attachment>,
|
||||
query: FindOptions<Attachment>
|
||||
) => Promise<void>
|
||||
) {
|
||||
if (!query.offset) query.offset = 0;
|
||||
if (!query.limit) query.limit = 10;
|
||||
let results;
|
||||
|
||||
do {
|
||||
results = await this.findAll(query);
|
||||
await callback(results, query);
|
||||
query.offset += query.limit;
|
||||
} while (results.length >= query.limit);
|
||||
}
|
||||
}
|
||||
|
||||
export default Attachment;
|
||||
|
||||
@@ -1,68 +1,89 @@
|
||||
import { Op } from "sequelize";
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
CreatedAt,
|
||||
DataType,
|
||||
Default,
|
||||
ForeignKey,
|
||||
HasMany,
|
||||
Table,
|
||||
Model,
|
||||
IsUUID,
|
||||
PrimaryKey,
|
||||
} from "sequelize-typescript";
|
||||
import { ValidationError } from "../errors";
|
||||
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'providers' implicitly has type 'any[]' i... Remove this comment to see the full error message
|
||||
import providers from "../routes/auth/providers";
|
||||
import { DataTypes, Op, sequelize } from "../sequelize";
|
||||
import Team from "./Team";
|
||||
import UserAuthentication from "./UserAuthentication";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const AuthenticationProvider = sequelize.define(
|
||||
"authentication_providers",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
validate: {
|
||||
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'providers' implicitly has an 'any[]' typ... Remove this comment to see the full error message
|
||||
isIn: [providers.map((p) => p.id)],
|
||||
@Table({
|
||||
tableName: "authentication_providers",
|
||||
modelName: "authentication_provider",
|
||||
updatedAt: false,
|
||||
})
|
||||
@Fix
|
||||
class AuthenticationProvider extends Model {
|
||||
@IsUUID(4)
|
||||
@PrimaryKey
|
||||
@Default(DataType.UUIDV4)
|
||||
@Column(DataType.UUID)
|
||||
id: string;
|
||||
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
@Default(true)
|
||||
@Column
|
||||
enabled: boolean;
|
||||
|
||||
@Column
|
||||
providerId: string;
|
||||
|
||||
@CreatedAt
|
||||
createdAt: Date;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => Team, "teamId")
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
|
||||
@HasMany(() => UserAuthentication, "providerId")
|
||||
userAuthentications: UserAuthentication[];
|
||||
|
||||
// instance methods
|
||||
|
||||
disable = async () => {
|
||||
const res = await (this
|
||||
.constructor as typeof AuthenticationProvider).findAndCountAll({
|
||||
where: {
|
||||
teamId: this.teamId,
|
||||
enabled: true,
|
||||
id: {
|
||||
[Op.ne]: this.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true,
|
||||
},
|
||||
providerId: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
updatedAt: false,
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
AuthenticationProvider.associate = (models) => {
|
||||
AuthenticationProvider.belongsTo(models.Team);
|
||||
AuthenticationProvider.hasMany(models.UserAuthentication);
|
||||
};
|
||||
|
||||
AuthenticationProvider.prototype.disable = async function () {
|
||||
const res = await AuthenticationProvider.findAndCountAll({
|
||||
where: {
|
||||
teamId: this.teamId,
|
||||
enabled: true,
|
||||
id: {
|
||||
[Op.ne]: this.id,
|
||||
},
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (res.count >= 1) {
|
||||
return this.update({
|
||||
enabled: false,
|
||||
limit: 1,
|
||||
});
|
||||
} else {
|
||||
throw ValidationError("At least one authentication provider is required");
|
||||
}
|
||||
};
|
||||
|
||||
AuthenticationProvider.prototype.enable = async function () {
|
||||
return this.update({
|
||||
enabled: true,
|
||||
});
|
||||
};
|
||||
if (res.count >= 1) {
|
||||
return this.update({
|
||||
enabled: false,
|
||||
});
|
||||
} else {
|
||||
throw ValidationError("At least one authentication provider is required");
|
||||
}
|
||||
};
|
||||
|
||||
enable = () => {
|
||||
return this.update({
|
||||
enabled: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default AuthenticationProvider;
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
import {
|
||||
DataType,
|
||||
BelongsTo,
|
||||
ForeignKey,
|
||||
Column,
|
||||
Table,
|
||||
} from "sequelize-typescript";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import BaseModel from "./base/BaseModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const Backlink = sequelize.define("backlink", {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
});
|
||||
@Table({ tableName: "backlinks", modelName: "backlink" })
|
||||
@Fix
|
||||
class Backlink extends BaseModel {
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
Backlink.associate = (models) => {
|
||||
Backlink.belongsTo(models.Document, {
|
||||
as: "document",
|
||||
foreignKey: "documentId",
|
||||
});
|
||||
Backlink.belongsTo(models.Document, {
|
||||
as: "reverseDocument",
|
||||
foreignKey: "reverseDocumentId",
|
||||
});
|
||||
Backlink.belongsTo(models.User, {
|
||||
as: "user",
|
||||
foreignKey: "userId",
|
||||
});
|
||||
};
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
@BelongsTo(() => Document, "documentId")
|
||||
document: Document;
|
||||
|
||||
@ForeignKey(() => Document)
|
||||
@Column(DataType.UUID)
|
||||
documentId: string;
|
||||
|
||||
@BelongsTo(() => Document, "reverseDocumentId")
|
||||
reverseDocument: Document;
|
||||
|
||||
@ForeignKey(() => Document)
|
||||
@Column(DataType.UUID)
|
||||
reverseDocumentId: string;
|
||||
}
|
||||
|
||||
export default Backlink;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import randomstring from "randomstring";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Collection, Document } from "@server/models";
|
||||
import {
|
||||
buildUser,
|
||||
buildGroup,
|
||||
@@ -10,9 +9,12 @@ import {
|
||||
} from "@server/test/factories";
|
||||
import { flushdb, seed } from "@server/test/support";
|
||||
import slugify from "@server/utils/slugify";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
beforeEach(jest.resetAllMocks);
|
||||
|
||||
describe("#url", () => {
|
||||
test("should return correct url for the collection", () => {
|
||||
const collection = new Collection({
|
||||
@@ -21,6 +23,7 @@ describe("#url", () => {
|
||||
expect(collection.url).toBe(`/collection/untitled-${collection.urlId}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDocumentParents", () => {
|
||||
test("should return array of parent document ids", async () => {
|
||||
const parent = await buildDocument();
|
||||
@@ -31,7 +34,7 @@ describe("getDocumentParents", () => {
|
||||
],
|
||||
});
|
||||
const result = collection.getDocumentParents(document.id);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result?.length).toBe(1);
|
||||
expect(result[0]).toBe(parent.id);
|
||||
});
|
||||
|
||||
@@ -44,7 +47,7 @@ describe("getDocumentParents", () => {
|
||||
],
|
||||
});
|
||||
const result = collection.getDocumentParents(parent.id);
|
||||
expect(result.length).toBe(0);
|
||||
expect(result?.length).toBe(0);
|
||||
});
|
||||
|
||||
test("should not error if documentStructure is empty", async () => {
|
||||
@@ -55,6 +58,7 @@ describe("getDocumentParents", () => {
|
||||
expect(result).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDocumentTree", () => {
|
||||
test("should return document tree", async () => {
|
||||
const document = await buildDocument();
|
||||
@@ -79,6 +83,7 @@ describe("getDocumentTree", () => {
|
||||
expect(collection.getDocumentTree(document.id)).toEqual(document.toJSON());
|
||||
});
|
||||
});
|
||||
|
||||
describe("isChildDocument", () => {
|
||||
test("should return false with unexpected data", async () => {
|
||||
const document = await buildDocument();
|
||||
@@ -128,6 +133,7 @@ describe("isChildDocument", () => {
|
||||
expect(collection.isChildDocument(document.id, parent.id)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#addDocumentToStructure", () => {
|
||||
test("should add as last element without index", async () => {
|
||||
const { collection } = await seed();
|
||||
@@ -138,8 +144,8 @@ describe("#addDocumentToStructure", () => {
|
||||
parentDocumentId: null,
|
||||
});
|
||||
await collection.addDocumentToStructure(newDocument);
|
||||
expect(collection.documentStructure.length).toBe(2);
|
||||
expect(collection.documentStructure[1].id).toBe(id);
|
||||
expect(collection.documentStructure!.length).toBe(2);
|
||||
expect(collection.documentStructure![1].id).toBe(id);
|
||||
});
|
||||
|
||||
test("should add with an index", async () => {
|
||||
@@ -151,8 +157,8 @@ describe("#addDocumentToStructure", () => {
|
||||
parentDocumentId: null,
|
||||
});
|
||||
await collection.addDocumentToStructure(newDocument, 1);
|
||||
expect(collection.documentStructure.length).toBe(2);
|
||||
expect(collection.documentStructure[1].id).toBe(id);
|
||||
expect(collection.documentStructure!.length).toBe(2);
|
||||
expect(collection.documentStructure![1].id).toBe(id);
|
||||
});
|
||||
|
||||
test("should add as a child if with parent", async () => {
|
||||
@@ -164,10 +170,10 @@ describe("#addDocumentToStructure", () => {
|
||||
parentDocumentId: document.id,
|
||||
});
|
||||
await collection.addDocumentToStructure(newDocument, 1);
|
||||
expect(collection.documentStructure.length).toBe(1);
|
||||
expect(collection.documentStructure[0].id).toBe(document.id);
|
||||
expect(collection.documentStructure[0].children.length).toBe(1);
|
||||
expect(collection.documentStructure[0].children[0].id).toBe(id);
|
||||
expect(collection.documentStructure!.length).toBe(1);
|
||||
expect(collection.documentStructure![0].id).toBe(document.id);
|
||||
expect(collection.documentStructure![0].children.length).toBe(1);
|
||||
expect(collection.documentStructure![0].children[0].id).toBe(id);
|
||||
});
|
||||
|
||||
test("should add as a child if with parent with index", async () => {
|
||||
@@ -185,10 +191,10 @@ describe("#addDocumentToStructure", () => {
|
||||
});
|
||||
await collection.addDocumentToStructure(newDocument);
|
||||
await collection.addDocumentToStructure(secondDocument, 0);
|
||||
expect(collection.documentStructure.length).toBe(1);
|
||||
expect(collection.documentStructure[0].id).toBe(document.id);
|
||||
expect(collection.documentStructure[0].children.length).toBe(2);
|
||||
expect(collection.documentStructure[0].children[0].id).toBe(id);
|
||||
expect(collection.documentStructure!.length).toBe(1);
|
||||
expect(collection.documentStructure![0].id).toBe(document.id);
|
||||
expect(collection.documentStructure![0].children.length).toBe(2);
|
||||
expect(collection.documentStructure![0].children[0].id).toBe(id);
|
||||
});
|
||||
describe("options: documentJson", () => {
|
||||
test("should append supplied json over document's own", async () => {
|
||||
@@ -201,27 +207,32 @@ describe("#addDocumentToStructure", () => {
|
||||
});
|
||||
await collection.addDocumentToStructure(newDocument, undefined, {
|
||||
documentJson: {
|
||||
id,
|
||||
title: "Parent",
|
||||
url: "parent",
|
||||
children: [
|
||||
{
|
||||
id,
|
||||
title: "Totally fake",
|
||||
children: [],
|
||||
url: "totally-fake",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(collection.documentStructure[1].children.length).toBe(1);
|
||||
expect(collection.documentStructure[1].children[0].id).toBe(id);
|
||||
expect(collection.documentStructure![1].children.length).toBe(1);
|
||||
expect(collection.documentStructure![1].children[0].id).toBe(id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("#updateDocument", () => {
|
||||
test("should update root document's data", async () => {
|
||||
const { collection, document } = await seed();
|
||||
document.title = "Updated title";
|
||||
await document.save();
|
||||
await collection.updateDocument(document);
|
||||
expect(collection.documentStructure[0].title).toBe("Updated title");
|
||||
expect(collection.documentStructure![0].title).toBe("Updated title");
|
||||
});
|
||||
|
||||
test("should update child document's data", async () => {
|
||||
@@ -241,11 +252,12 @@ describe("#updateDocument", () => {
|
||||
await newDocument.save();
|
||||
await collection.updateDocument(newDocument);
|
||||
const reloaded = await Collection.findByPk(collection.id);
|
||||
expect(reloaded.documentStructure[0].children[0].title).toBe(
|
||||
expect(reloaded!.documentStructure![0].children[0].title).toBe(
|
||||
"Updated title"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#removeDocument", () => {
|
||||
test("should save if removing", async () => {
|
||||
const { collection, document } = await seed();
|
||||
@@ -257,7 +269,7 @@ describe("#removeDocument", () => {
|
||||
test("should remove documents from root", async () => {
|
||||
const { collection, document } = await seed();
|
||||
await collection.deleteDocument(document);
|
||||
expect(collection.documentStructure.length).toBe(0);
|
||||
expect(collection.documentStructure!.length).toBe(0);
|
||||
// Verify that the document was removed
|
||||
const collectionDocuments = await Document.findAndCountAll({
|
||||
where: {
|
||||
@@ -281,10 +293,10 @@ describe("#removeDocument", () => {
|
||||
text: "content",
|
||||
});
|
||||
await collection.addDocumentToStructure(newDocument);
|
||||
expect(collection.documentStructure[0].children.length).toBe(1);
|
||||
expect(collection.documentStructure![0].children.length).toBe(1);
|
||||
// Remove the document
|
||||
await collection.deleteDocument(document);
|
||||
expect(collection.documentStructure.length).toBe(0);
|
||||
expect(collection.documentStructure!.length).toBe(0);
|
||||
const collectionDocuments = await Document.findAndCountAll({
|
||||
where: {
|
||||
collectionId: collection.id,
|
||||
@@ -308,13 +320,13 @@ describe("#removeDocument", () => {
|
||||
text: "content",
|
||||
});
|
||||
await collection.addDocumentToStructure(newDocument);
|
||||
expect(collection.documentStructure.length).toBe(1);
|
||||
expect(collection.documentStructure[0].children.length).toBe(1);
|
||||
expect(collection.documentStructure!.length).toBe(1);
|
||||
expect(collection.documentStructure![0].children.length).toBe(1);
|
||||
// Remove the document
|
||||
await collection.deleteDocument(newDocument);
|
||||
const reloaded = await Collection.findByPk(collection.id);
|
||||
expect(reloaded.documentStructure.length).toBe(1);
|
||||
expect(reloaded.documentStructure[0].children.length).toBe(0);
|
||||
expect(reloaded!.documentStructure!.length).toBe(1);
|
||||
expect(reloaded!.documentStructure![0].children.length).toBe(0);
|
||||
const collectionDocuments = await Document.findAndCountAll({
|
||||
where: {
|
||||
collectionId: collection.id,
|
||||
@@ -323,6 +335,7 @@ describe("#removeDocument", () => {
|
||||
expect(collectionDocuments.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#membershipUserIds", () => {
|
||||
test("should return collection and group memberships", async () => {
|
||||
const team = await buildTeam();
|
||||
@@ -350,42 +363,42 @@ describe("#membershipUserIds", () => {
|
||||
teamId,
|
||||
});
|
||||
const createdById = users[0].id;
|
||||
await group1.addUser(users[0], {
|
||||
await group1.$add("user", users[0], {
|
||||
through: {
|
||||
createdById,
|
||||
},
|
||||
});
|
||||
await group1.addUser(users[1], {
|
||||
await group1.$add("user", users[1], {
|
||||
through: {
|
||||
createdById,
|
||||
},
|
||||
});
|
||||
await group2.addUser(users[2], {
|
||||
await group2.$add("user", users[2], {
|
||||
through: {
|
||||
createdById,
|
||||
},
|
||||
});
|
||||
await group2.addUser(users[3], {
|
||||
await group2.$add("user", users[3], {
|
||||
through: {
|
||||
createdById,
|
||||
},
|
||||
});
|
||||
await collection.addUser(users[4], {
|
||||
await collection.$add("user", users[4], {
|
||||
through: {
|
||||
createdById,
|
||||
},
|
||||
});
|
||||
await collection.addUser(users[5], {
|
||||
await collection.$add("user", users[5], {
|
||||
through: {
|
||||
createdById,
|
||||
},
|
||||
});
|
||||
await collection.addGroup(group1, {
|
||||
await collection.$add("group", group1, {
|
||||
through: {
|
||||
createdById,
|
||||
},
|
||||
});
|
||||
await collection.addGroup(group2, {
|
||||
await collection.$add("group", group2, {
|
||||
through: {
|
||||
createdById,
|
||||
},
|
||||
@@ -394,18 +407,19 @@ describe("#membershipUserIds", () => {
|
||||
expect(membershipUserIds.length).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#findByPk", () => {
|
||||
test("should return collection with collection Id", async () => {
|
||||
const collection = await buildCollection();
|
||||
const response = await Collection.findByPk(collection.id);
|
||||
expect(response.id).toBe(collection.id);
|
||||
expect(response!.id).toBe(collection.id);
|
||||
});
|
||||
|
||||
test("should return collection when urlId is present", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = `${slugify(collection.name)}-${collection.urlId}`;
|
||||
const response = await Collection.findByPk(id);
|
||||
expect(response.id).toBe(collection.id);
|
||||
expect(response!.id).toBe(collection.id);
|
||||
});
|
||||
|
||||
test("should return undefined when incorrect uuid type", async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,44 @@
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
Default,
|
||||
ForeignKey,
|
||||
IsIn,
|
||||
Model,
|
||||
Table,
|
||||
DataType,
|
||||
} from "sequelize-typescript";
|
||||
import Collection from "./Collection";
|
||||
import Group from "./Group";
|
||||
import User from "./User";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const CollectionGroup = sequelize.define(
|
||||
"collection_group",
|
||||
{
|
||||
permission: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: "read_write",
|
||||
validate: {
|
||||
isIn: [["read", "read_write", "maintainer"]],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
paranoid: true,
|
||||
}
|
||||
);
|
||||
@Table({ tableName: "collection_groups", modelName: "collection_group" })
|
||||
@Fix
|
||||
class CollectionGroup extends Model {
|
||||
@Default("read_write")
|
||||
@IsIn([["read", "read_write", "maintainer"]])
|
||||
@Column
|
||||
permission: string;
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
CollectionGroup.associate = (models) => {
|
||||
CollectionGroup.belongsTo(models.Collection, {
|
||||
as: "collection",
|
||||
foreignKey: "collectionId",
|
||||
primary: true,
|
||||
});
|
||||
CollectionGroup.belongsTo(models.Group, {
|
||||
as: "group",
|
||||
foreignKey: "groupId",
|
||||
primary: true,
|
||||
});
|
||||
CollectionGroup.belongsTo(models.User, {
|
||||
as: "createdBy",
|
||||
foreignKey: "createdById",
|
||||
});
|
||||
};
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => Collection, "collectionId")
|
||||
collection: Collection;
|
||||
|
||||
@ForeignKey(() => Collection)
|
||||
@Column(DataType.UUID)
|
||||
collectionId: string;
|
||||
|
||||
@BelongsTo(() => Group, "groupId")
|
||||
group: Group;
|
||||
|
||||
@ForeignKey(() => Group)
|
||||
@Column(DataType.UUID)
|
||||
groupId: string;
|
||||
|
||||
@BelongsTo(() => User, "createdById")
|
||||
createdBy: User;
|
||||
}
|
||||
|
||||
export default CollectionGroup;
|
||||
|
||||
@@ -1,35 +1,47 @@
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
import {
|
||||
Column,
|
||||
ForeignKey,
|
||||
BelongsTo,
|
||||
Default,
|
||||
IsIn,
|
||||
Table,
|
||||
DataType,
|
||||
Model,
|
||||
} from "sequelize-typescript";
|
||||
import Collection from "./Collection";
|
||||
import User from "./User";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const CollectionUser = sequelize.define(
|
||||
"collection_user",
|
||||
{
|
||||
permission: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: "read_write",
|
||||
validate: {
|
||||
isIn: [["read", "read_write", "maintainer"]],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
}
|
||||
);
|
||||
@Table({ tableName: "collection_users", modelName: "collection_user" })
|
||||
@Fix
|
||||
class CollectionUser extends Model {
|
||||
@Default("read_write")
|
||||
@IsIn([["read", "read_write", "maintainer"]])
|
||||
@Column
|
||||
permission: string;
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
CollectionUser.associate = (models) => {
|
||||
CollectionUser.belongsTo(models.Collection, {
|
||||
as: "collection",
|
||||
foreignKey: "collectionId",
|
||||
});
|
||||
CollectionUser.belongsTo(models.User, {
|
||||
as: "user",
|
||||
foreignKey: "userId",
|
||||
});
|
||||
CollectionUser.belongsTo(models.User, {
|
||||
as: "createdBy",
|
||||
foreignKey: "createdById",
|
||||
});
|
||||
};
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => Collection, "collectionId")
|
||||
collection: Collection;
|
||||
|
||||
@ForeignKey(() => Collection)
|
||||
@Column(DataType.UUID)
|
||||
collectionId: string;
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
@BelongsTo(() => User, "createdById")
|
||||
createdBy: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
createdById: string;
|
||||
}
|
||||
|
||||
export default CollectionUser;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Document } from "@server/models";
|
||||
import Document from "@server/models/Document";
|
||||
import {
|
||||
buildDocument,
|
||||
buildCollection,
|
||||
@@ -10,6 +10,7 @@ import slugify from "@server/utils/slugify";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
beforeEach(jest.resetAllMocks);
|
||||
|
||||
describe("#getSummary", () => {
|
||||
test("should strip markdown", async () => {
|
||||
const document = await buildDocument({
|
||||
@@ -23,7 +24,7 @@ paragraph 2`,
|
||||
|
||||
test("should strip title when no version", async () => {
|
||||
const document = await buildDocument({
|
||||
version: null,
|
||||
version: 0,
|
||||
text: `# Heading
|
||||
|
||||
*paragraph*`,
|
||||
@@ -31,6 +32,7 @@ paragraph 2`,
|
||||
expect(document.getSummary()).toBe("paragraph");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#migrateVersion", () => {
|
||||
test("should maintain empty paragraph under headings", async () => {
|
||||
const document = await buildDocument({
|
||||
@@ -155,6 +157,7 @@ paragraph`);
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#searchForTeam", () => {
|
||||
test("should return search results from public collections", async () => {
|
||||
const team = await buildTeam();
|
||||
@@ -168,7 +171,7 @@ describe("#searchForTeam", () => {
|
||||
});
|
||||
const { results } = await Document.searchForTeam(team, "test");
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].document.id).toBe(document.id);
|
||||
expect(results[0].document?.id).toBe(document.id);
|
||||
});
|
||||
|
||||
test("should not return search results from private collections", async () => {
|
||||
@@ -252,6 +255,7 @@ describe("#searchForTeam", () => {
|
||||
expect(totalCount).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#searchForUser", () => {
|
||||
test("should return search results from collections", async () => {
|
||||
const team = await buildTeam();
|
||||
@@ -270,7 +274,7 @@ describe("#searchForUser", () => {
|
||||
});
|
||||
const { results } = await Document.searchForUser(user, "test");
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].document.id).toBe(document.id);
|
||||
expect(results[0].document?.id).toBe(document.id);
|
||||
});
|
||||
|
||||
test("should handle no collections", async () => {
|
||||
@@ -352,44 +356,47 @@ describe("#searchForUser", () => {
|
||||
expect(totalCount).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#delete", () => {
|
||||
test("should soft delete and set last modified", async () => {
|
||||
let document = await buildDocument();
|
||||
const document = await buildDocument();
|
||||
const user = await buildUser();
|
||||
await document.delete(user.id);
|
||||
document = await Document.findByPk(document.id, {
|
||||
|
||||
const newDocument = await Document.findByPk(document.id, {
|
||||
paranoid: false,
|
||||
});
|
||||
expect(document.lastModifiedById).toBe(user.id);
|
||||
expect(document.deletedAt).toBeTruthy();
|
||||
expect(newDocument?.lastModifiedById).toBe(user.id);
|
||||
expect(newDocument?.deletedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should soft delete templates", async () => {
|
||||
let document = await buildDocument({
|
||||
const document = await buildDocument({
|
||||
template: true,
|
||||
});
|
||||
const user = await buildUser();
|
||||
await document.delete(user.id);
|
||||
document = await Document.findByPk(document.id, {
|
||||
const newDocument = await Document.findByPk(document.id, {
|
||||
paranoid: false,
|
||||
});
|
||||
expect(document.lastModifiedById).toBe(user.id);
|
||||
expect(document.deletedAt).toBeTruthy();
|
||||
expect(newDocument?.lastModifiedById).toBe(user.id);
|
||||
expect(newDocument?.deletedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should soft delete archived", async () => {
|
||||
let document = await buildDocument({
|
||||
const document = await buildDocument({
|
||||
archivedAt: new Date(),
|
||||
});
|
||||
const user = await buildUser();
|
||||
await document.delete(user.id);
|
||||
document = await Document.findByPk(document.id, {
|
||||
const newDocument = await Document.findByPk(document.id, {
|
||||
paranoid: false,
|
||||
});
|
||||
expect(document.lastModifiedById).toBe(user.id);
|
||||
expect(document.deletedAt).toBeTruthy();
|
||||
expect(newDocument?.lastModifiedById).toBe(user.id);
|
||||
expect(newDocument?.deletedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#save", () => {
|
||||
test("should have empty previousTitles by default", async () => {
|
||||
const document = await buildDocument();
|
||||
@@ -414,14 +421,16 @@ describe("#save", () => {
|
||||
expect(document.previousTitles.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#findByPk", () => {
|
||||
test("should return document when urlId is correct", async () => {
|
||||
const { document } = await seed();
|
||||
const id = `${slugify(document.title)}-${document.urlId}`;
|
||||
const response = await Document.findByPk(id);
|
||||
expect(response.id).toBe(document.id);
|
||||
expect(response?.id).toBe(document.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tasks", () => {
|
||||
test("should consider all the possible checkTtems", async () => {
|
||||
const document = await buildDocument({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,126 +1,164 @@
|
||||
import {
|
||||
ForeignKey,
|
||||
AfterCreate,
|
||||
BeforeCreate,
|
||||
BelongsTo,
|
||||
Column,
|
||||
IsIP,
|
||||
IsUUID,
|
||||
Table,
|
||||
DataType,
|
||||
} from "sequelize-typescript";
|
||||
import { globalEventQueue } from "../queues";
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import BaseModel from "./base/BaseModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const Event = sequelize.define("event", {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
modelId: DataTypes.UUID,
|
||||
name: DataTypes.STRING,
|
||||
ip: DataTypes.STRING,
|
||||
data: DataTypes.JSONB,
|
||||
});
|
||||
@Table({ tableName: "events", modelName: "event" })
|
||||
@Fix
|
||||
class Event extends BaseModel {
|
||||
@IsUUID(4)
|
||||
@Column(DataType.UUID)
|
||||
modelId: string;
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
Event.associate = (models) => {
|
||||
Event.belongsTo(models.User, {
|
||||
as: "user",
|
||||
foreignKey: "userId",
|
||||
});
|
||||
Event.belongsTo(models.User, {
|
||||
as: "actor",
|
||||
foreignKey: "actorId",
|
||||
});
|
||||
Event.belongsTo(models.Collection, {
|
||||
as: "collection",
|
||||
foreignKey: "collectionId",
|
||||
});
|
||||
Event.belongsTo(models.Collection, {
|
||||
as: "document",
|
||||
foreignKey: "documentId",
|
||||
});
|
||||
Event.belongsTo(models.Team, {
|
||||
as: "team",
|
||||
foreignKey: "teamId",
|
||||
});
|
||||
};
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'event' implicitly has an 'any' type.
|
||||
Event.beforeCreate((event) => {
|
||||
if (event.ip) {
|
||||
// cleanup IPV6 representations of IPV4 addresses
|
||||
event.ip = event.ip.replace(/^::ffff:/, "");
|
||||
@IsIP
|
||||
@Column
|
||||
ip: string | null;
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
data: Record<string, any>;
|
||||
|
||||
// hooks
|
||||
|
||||
@BeforeCreate
|
||||
static cleanupIp(model: Event) {
|
||||
if (model.ip) {
|
||||
// cleanup IPV6 representations of IPV4 addresses
|
||||
model.ip = model.ip.replace(/^::ffff:/, "");
|
||||
}
|
||||
}
|
||||
});
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'event' implicitly has an 'any' type.
|
||||
Event.afterCreate((event) => {
|
||||
globalEventQueue.add(event);
|
||||
});
|
||||
|
||||
// add can be used to send events into the event system without recording them
|
||||
// in the database or audit trail
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'event' implicitly has an 'any' type.
|
||||
Event.add = (event) => {
|
||||
const now = new Date();
|
||||
globalEventQueue.add(
|
||||
Event.build({
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...event,
|
||||
})
|
||||
);
|
||||
};
|
||||
@AfterCreate
|
||||
static async enqueue(model: Event) {
|
||||
globalEventQueue.add(model);
|
||||
}
|
||||
|
||||
Event.ACTIVITY_EVENTS = [
|
||||
"collections.create",
|
||||
"collections.delete",
|
||||
"collections.move",
|
||||
"collections.permission_changed",
|
||||
"documents.publish",
|
||||
"documents.archive",
|
||||
"documents.unarchive",
|
||||
"documents.move",
|
||||
"documents.delete",
|
||||
"documents.permanent_delete",
|
||||
"documents.restore",
|
||||
"revisions.create",
|
||||
"users.create",
|
||||
];
|
||||
Event.AUDIT_EVENTS = [
|
||||
"api_keys.create",
|
||||
"api_keys.delete",
|
||||
"authenticationProviders.update",
|
||||
"collections.create",
|
||||
"collections.update",
|
||||
"collections.permission_changed",
|
||||
"collections.move",
|
||||
"collections.add_user",
|
||||
"collections.remove_user",
|
||||
"collections.add_group",
|
||||
"collections.remove_group",
|
||||
"collections.delete",
|
||||
"collections.export_all",
|
||||
"documents.create",
|
||||
"documents.publish",
|
||||
"documents.update",
|
||||
"documents.archive",
|
||||
"documents.unarchive",
|
||||
"documents.move",
|
||||
"documents.delete",
|
||||
"documents.permanent_delete",
|
||||
"documents.restore",
|
||||
"groups.create",
|
||||
"groups.update",
|
||||
"groups.delete",
|
||||
"pins.create",
|
||||
"pins.update",
|
||||
"pins.delete",
|
||||
"revisions.create",
|
||||
"shares.create",
|
||||
"shares.update",
|
||||
"shares.revoke",
|
||||
"teams.update",
|
||||
"users.create",
|
||||
"users.update",
|
||||
"users.signin",
|
||||
"users.promote",
|
||||
"users.demote",
|
||||
"users.invite",
|
||||
"users.suspend",
|
||||
"users.activate",
|
||||
"users.delete",
|
||||
];
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
@BelongsTo(() => Document, "documentId")
|
||||
document: Document;
|
||||
|
||||
@ForeignKey(() => Document)
|
||||
@Column(DataType.UUID)
|
||||
documentId: string;
|
||||
|
||||
@BelongsTo(() => User, "actorId")
|
||||
actor: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
actorId: string;
|
||||
|
||||
@BelongsTo(() => Collection, "collectionId")
|
||||
collection: Collection;
|
||||
|
||||
@ForeignKey(() => Collection)
|
||||
@Column(DataType.UUID)
|
||||
collectionId: string;
|
||||
|
||||
@BelongsTo(() => Team, "teamId")
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
|
||||
// add can be used to send events into the event system without recording them
|
||||
// in the database or audit trail
|
||||
static add(event: Partial<Event>) {
|
||||
const now = new Date();
|
||||
globalEventQueue.add(
|
||||
this.build({
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...event,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
static ACTIVITY_EVENTS = [
|
||||
"collections.create",
|
||||
"collections.delete",
|
||||
"collections.move",
|
||||
"collections.permission_changed",
|
||||
"documents.publish",
|
||||
"documents.archive",
|
||||
"documents.unarchive",
|
||||
"documents.move",
|
||||
"documents.delete",
|
||||
"documents.permanent_delete",
|
||||
"documents.restore",
|
||||
"revisions.create",
|
||||
"users.create",
|
||||
];
|
||||
|
||||
static AUDIT_EVENTS = [
|
||||
"api_keys.create",
|
||||
"api_keys.delete",
|
||||
"authenticationProviders.update",
|
||||
"collections.create",
|
||||
"collections.update",
|
||||
"collections.permission_changed",
|
||||
"collections.move",
|
||||
"collections.add_user",
|
||||
"collections.remove_user",
|
||||
"collections.add_group",
|
||||
"collections.remove_group",
|
||||
"collections.delete",
|
||||
"collections.export_all",
|
||||
"documents.create",
|
||||
"documents.publish",
|
||||
"documents.update",
|
||||
"documents.archive",
|
||||
"documents.unarchive",
|
||||
"documents.move",
|
||||
"documents.delete",
|
||||
"documents.permanent_delete",
|
||||
"documents.restore",
|
||||
"groups.create",
|
||||
"groups.update",
|
||||
"groups.delete",
|
||||
"pins.create",
|
||||
"pins.update",
|
||||
"pins.delete",
|
||||
"revisions.create",
|
||||
"shares.create",
|
||||
"shares.update",
|
||||
"shares.revoke",
|
||||
"teams.update",
|
||||
"users.create",
|
||||
"users.update",
|
||||
"users.signin",
|
||||
"users.promote",
|
||||
"users.demote",
|
||||
"users.invite",
|
||||
"users.suspend",
|
||||
"users.activate",
|
||||
"users.delete",
|
||||
];
|
||||
}
|
||||
|
||||
export default Event;
|
||||
|
||||
@@ -1,76 +1,88 @@
|
||||
import {
|
||||
ForeignKey,
|
||||
DefaultScope,
|
||||
Column,
|
||||
BeforeDestroy,
|
||||
BelongsTo,
|
||||
Table,
|
||||
DataType,
|
||||
} from "sequelize-typescript";
|
||||
import { deleteFromS3 } from "@server/utils/s3";
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
import Collection from "./Collection";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import BaseModel from "./base/BaseModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const FileOperation = sequelize.define("file_operations", {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
type: {
|
||||
type: DataTypes.ENUM("import", "export"),
|
||||
allowNull: false,
|
||||
},
|
||||
state: {
|
||||
type: DataTypes.ENUM(
|
||||
"creating",
|
||||
"uploading",
|
||||
"complete",
|
||||
"error",
|
||||
"expired"
|
||||
),
|
||||
allowNull: false,
|
||||
},
|
||||
key: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
url: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
size: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
},
|
||||
});
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'model' implicitly has an 'any' type.
|
||||
FileOperation.beforeDestroy(async (model) => {
|
||||
await deleteFromS3(model.key);
|
||||
});
|
||||
@DefaultScope(() => ({
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "user",
|
||||
paranoid: false,
|
||||
},
|
||||
{
|
||||
model: Collection,
|
||||
as: "collection",
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
}))
|
||||
@Table({ tableName: "file_operations", modelName: "file_operation" })
|
||||
@Fix
|
||||
class FileOperation extends BaseModel {
|
||||
@Column(DataType.ENUM("import", "export"))
|
||||
type: "import" | "export";
|
||||
|
||||
FileOperation.prototype.expire = async function () {
|
||||
this.state = "expired";
|
||||
await deleteFromS3(this.key);
|
||||
await this.save();
|
||||
};
|
||||
@Column(
|
||||
DataType.ENUM("creating", "uploading", "complete", "error", "expired")
|
||||
)
|
||||
state: "creating" | "uploading" | "complete" | "error" | "expired";
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
FileOperation.associate = (models) => {
|
||||
FileOperation.belongsTo(models.User, {
|
||||
as: "user",
|
||||
foreignKey: "userId",
|
||||
});
|
||||
FileOperation.belongsTo(models.Collection, {
|
||||
as: "collection",
|
||||
foreignKey: "collectionId",
|
||||
});
|
||||
FileOperation.belongsTo(models.Team, {
|
||||
as: "team",
|
||||
foreignKey: "teamId",
|
||||
});
|
||||
FileOperation.addScope("defaultScope", {
|
||||
include: [
|
||||
{
|
||||
model: models.User,
|
||||
as: "user",
|
||||
paranoid: false,
|
||||
},
|
||||
{
|
||||
model: models.Collection,
|
||||
as: "collection",
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
@Column
|
||||
key: string;
|
||||
|
||||
@Column
|
||||
url: string;
|
||||
|
||||
@Column(DataType.BIGINT)
|
||||
size: number;
|
||||
|
||||
expire = async function () {
|
||||
this.state = "expired";
|
||||
await deleteFromS3(this.key);
|
||||
await this.save();
|
||||
};
|
||||
|
||||
// hooks
|
||||
|
||||
@BeforeDestroy
|
||||
static async deleteFileFromS3(model: FileOperation) {
|
||||
await deleteFromS3(model.key);
|
||||
}
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
@BelongsTo(() => Team, "teamId")
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
|
||||
@BelongsTo(() => Collection, "collectionId")
|
||||
collection: Collection;
|
||||
|
||||
@ForeignKey(() => Collection)
|
||||
@Column(DataType.UUID)
|
||||
collectionId: string;
|
||||
}
|
||||
|
||||
export default FileOperation;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CollectionGroup, GroupUser } from "@server/models";
|
||||
import { buildUser, buildGroup, buildCollection } from "@server/test/factories";
|
||||
import { flushdb } from "@server/test/support";
|
||||
import CollectionGroup from "./CollectionGroup";
|
||||
import GroupUser from "./GroupUser";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
beforeEach(jest.resetAllMocks);
|
||||
|
||||
describe("afterDestroy hook", () => {
|
||||
test("should destroy associated group and collection join relations", async () => {
|
||||
const group = await buildGroup();
|
||||
@@ -23,22 +25,22 @@ describe("afterDestroy hook", () => {
|
||||
teamId,
|
||||
});
|
||||
const createdById = user1.id;
|
||||
await group.addUser(user1, {
|
||||
await group.$add("user", user1, {
|
||||
through: {
|
||||
createdById,
|
||||
},
|
||||
});
|
||||
await group.addUser(user2, {
|
||||
await group.$add("user", user2, {
|
||||
through: {
|
||||
createdById,
|
||||
},
|
||||
});
|
||||
await collection1.addGroup(group, {
|
||||
await collection1.$add("group", group, {
|
||||
through: {
|
||||
createdById,
|
||||
},
|
||||
});
|
||||
await collection2.addGroup(group, {
|
||||
await collection2.$add("group", group, {
|
||||
through: {
|
||||
createdById,
|
||||
},
|
||||
|
||||
@@ -1,96 +1,100 @@
|
||||
import { CollectionGroup, GroupUser } from "@server/models";
|
||||
import { Op, DataTypes, sequelize } from "../sequelize";
|
||||
import { Op } from "sequelize";
|
||||
import {
|
||||
AfterDestroy,
|
||||
BelongsTo,
|
||||
Column,
|
||||
ForeignKey,
|
||||
Table,
|
||||
HasMany,
|
||||
BelongsToMany,
|
||||
DefaultScope,
|
||||
DataType,
|
||||
} from "sequelize-typescript";
|
||||
import CollectionGroup from "./CollectionGroup";
|
||||
import GroupUser from "./GroupUser";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const Group = sequelize.define(
|
||||
"group",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
@DefaultScope(() => ({
|
||||
include: [
|
||||
{
|
||||
association: "groupMemberships",
|
||||
required: false,
|
||||
},
|
||||
teamId: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
],
|
||||
order: [["name", "ASC"]],
|
||||
}))
|
||||
@Table({
|
||||
tableName: "groups",
|
||||
modelName: "group",
|
||||
validate: {
|
||||
isUniqueNameInTeam: async function () {
|
||||
const foundItem = await Group.findOne({
|
||||
where: {
|
||||
teamId: this.teamId,
|
||||
name: {
|
||||
[Op.iLike]: this.name,
|
||||
},
|
||||
id: {
|
||||
[Op.not]: this.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (foundItem) {
|
||||
throw new Error("The name of this group is already in use");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
paranoid: true,
|
||||
validate: {
|
||||
isUniqueNameInTeam: async function () {
|
||||
const foundItem = await Group.findOne({
|
||||
where: {
|
||||
teamId: this.teamId,
|
||||
name: {
|
||||
[Op.iLike]: this.name,
|
||||
},
|
||||
id: {
|
||||
[Op.not]: this.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
@Fix
|
||||
class Group extends ParanoidModel {
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
if (foundItem) {
|
||||
throw new Error("The name of this group is already in use");
|
||||
}
|
||||
// hooks
|
||||
|
||||
@AfterDestroy
|
||||
static async deleteGroupUsers(model: Group) {
|
||||
if (!model.deletedAt) return;
|
||||
await GroupUser.destroy({
|
||||
where: {
|
||||
groupId: model.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
await CollectionGroup.destroy({
|
||||
where: {
|
||||
groupId: model.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
Group.associate = (models) => {
|
||||
Group.hasMany(models.GroupUser, {
|
||||
as: "groupMemberships",
|
||||
foreignKey: "groupId",
|
||||
});
|
||||
Group.hasMany(models.CollectionGroup, {
|
||||
as: "collectionGroupMemberships",
|
||||
foreignKey: "groupId",
|
||||
});
|
||||
Group.belongsTo(models.Team, {
|
||||
as: "team",
|
||||
foreignKey: "teamId",
|
||||
});
|
||||
Group.belongsTo(models.User, {
|
||||
as: "createdBy",
|
||||
foreignKey: "createdById",
|
||||
});
|
||||
Group.belongsToMany(models.User, {
|
||||
as: "users",
|
||||
through: models.GroupUser,
|
||||
foreignKey: "groupId",
|
||||
});
|
||||
Group.addScope("defaultScope", {
|
||||
include: [
|
||||
{
|
||||
association: "groupMemberships",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
order: [["name", "ASC"]],
|
||||
});
|
||||
};
|
||||
// associations
|
||||
|
||||
// Cascade deletes to group and collection relations
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'group' implicitly has an 'any' type.
|
||||
Group.addHook("afterDestroy", async (group) => {
|
||||
if (!group.deletedAt) return;
|
||||
await GroupUser.destroy({
|
||||
where: {
|
||||
groupId: group.id,
|
||||
},
|
||||
});
|
||||
await CollectionGroup.destroy({
|
||||
where: {
|
||||
groupId: group.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
@HasMany(() => GroupUser, "groupId")
|
||||
groupMemberships: GroupUser[];
|
||||
|
||||
@HasMany(() => CollectionGroup, "groupId")
|
||||
collectionGroupMemberships: CollectionGroup[];
|
||||
|
||||
@BelongsTo(() => Team, "teamId")
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
|
||||
@BelongsTo(() => User, "createdById")
|
||||
createdBy: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
createdById: string;
|
||||
|
||||
@BelongsToMany(() => User, () => GroupUser)
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export default Group;
|
||||
|
||||
@@ -1,37 +1,46 @@
|
||||
import { sequelize } from "../sequelize";
|
||||
import {
|
||||
DefaultScope,
|
||||
BelongsTo,
|
||||
ForeignKey,
|
||||
Column,
|
||||
Table,
|
||||
DataType,
|
||||
Model,
|
||||
} from "sequelize-typescript";
|
||||
import Group from "./Group";
|
||||
import User from "./User";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const GroupUser = sequelize.define(
|
||||
"group_user",
|
||||
{},
|
||||
{
|
||||
timestamps: true,
|
||||
paranoid: true,
|
||||
}
|
||||
);
|
||||
@DefaultScope(() => ({
|
||||
include: [
|
||||
{
|
||||
association: "user",
|
||||
},
|
||||
],
|
||||
}))
|
||||
@Table({ tableName: "group_users", modelName: "group_user", paranoid: true })
|
||||
@Fix
|
||||
class GroupUser extends Model {
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
GroupUser.associate = (models) => {
|
||||
GroupUser.belongsTo(models.Group, {
|
||||
as: "group",
|
||||
foreignKey: "groupId",
|
||||
primary: true,
|
||||
});
|
||||
GroupUser.belongsTo(models.User, {
|
||||
as: "user",
|
||||
foreignKey: "userId",
|
||||
primary: true,
|
||||
});
|
||||
GroupUser.belongsTo(models.User, {
|
||||
as: "createdBy",
|
||||
foreignKey: "createdById",
|
||||
});
|
||||
GroupUser.addScope("defaultScope", {
|
||||
include: [
|
||||
{
|
||||
association: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
@BelongsTo(() => Group, "groupId")
|
||||
group: Group;
|
||||
|
||||
@ForeignKey(() => Group)
|
||||
@Column(DataType.UUID)
|
||||
groupId: string;
|
||||
|
||||
@BelongsTo(() => User, "createdById")
|
||||
createdBy: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
createdById: string;
|
||||
}
|
||||
|
||||
export default GroupUser;
|
||||
|
||||
@@ -1,35 +1,61 @@
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
import {
|
||||
ForeignKey,
|
||||
BelongsTo,
|
||||
Column,
|
||||
Table,
|
||||
DataType,
|
||||
} from "sequelize-typescript";
|
||||
import Collection from "./Collection";
|
||||
import IntegrationAuthentication from "./IntegrationAuthentication";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import BaseModel from "./base/BaseModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const Integration = sequelize.define("integration", {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
type: DataTypes.STRING,
|
||||
service: DataTypes.STRING,
|
||||
settings: DataTypes.JSONB,
|
||||
events: DataTypes.ARRAY(DataTypes.STRING),
|
||||
});
|
||||
@Table({ tableName: "integrations", modelName: "integration" })
|
||||
@Fix
|
||||
class Integration extends BaseModel {
|
||||
@Column
|
||||
type: string;
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
Integration.associate = (models) => {
|
||||
Integration.belongsTo(models.User, {
|
||||
as: "user",
|
||||
foreignKey: "userId",
|
||||
});
|
||||
Integration.belongsTo(models.Team, {
|
||||
as: "team",
|
||||
foreignKey: "teamId",
|
||||
});
|
||||
Integration.belongsTo(models.Collection, {
|
||||
as: "collection",
|
||||
foreignKey: "collectionId",
|
||||
});
|
||||
Integration.belongsTo(models.IntegrationAuthentication, {
|
||||
as: "authentication",
|
||||
foreignKey: "authenticationId",
|
||||
});
|
||||
};
|
||||
@Column
|
||||
service: string;
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
settings: any;
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
events: string[];
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
@BelongsTo(() => Team, "teamId")
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
|
||||
@BelongsTo(() => Collection, "collectionId")
|
||||
collection: Collection;
|
||||
|
||||
@ForeignKey(() => Collection)
|
||||
@Column(DataType.UUID)
|
||||
collectionId: string;
|
||||
|
||||
@BelongsTo(() => IntegrationAuthentication, "authenticationId")
|
||||
authentication: IntegrationAuthentication;
|
||||
|
||||
@ForeignKey(() => IntegrationAuthentication)
|
||||
@Column(DataType.UUID)
|
||||
authenticationId: string;
|
||||
}
|
||||
|
||||
export default Integration;
|
||||
|
||||
@@ -1,26 +1,53 @@
|
||||
import { DataTypes, sequelize, encryptedFields } from "../sequelize";
|
||||
import {
|
||||
DataType,
|
||||
Table,
|
||||
ForeignKey,
|
||||
BelongsTo,
|
||||
Column,
|
||||
} from "sequelize-typescript";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import BaseModel from "./base/BaseModel";
|
||||
import Encrypted, {
|
||||
getEncryptedColumn,
|
||||
setEncryptedColumn,
|
||||
} from "./decorators/Encrypted";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const IntegrationAuthentication = sequelize.define("authentication", {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
service: DataTypes.STRING,
|
||||
scopes: DataTypes.ARRAY(DataTypes.STRING),
|
||||
token: encryptedFields().vault("token"),
|
||||
});
|
||||
@Table({ tableName: "authentications", modelName: "authentication" })
|
||||
@Fix
|
||||
class IntegrationAuthentication extends BaseModel {
|
||||
@Column
|
||||
service: string;
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
IntegrationAuthentication.associate = (models) => {
|
||||
IntegrationAuthentication.belongsTo(models.User, {
|
||||
as: "user",
|
||||
foreignKey: "userId",
|
||||
});
|
||||
IntegrationAuthentication.belongsTo(models.Team, {
|
||||
as: "team",
|
||||
foreignKey: "teamId",
|
||||
});
|
||||
};
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
scopes: string[];
|
||||
|
||||
@Column(DataType.BLOB)
|
||||
@Encrypted
|
||||
get token() {
|
||||
return getEncryptedColumn(this, "token");
|
||||
}
|
||||
|
||||
set token(value: string) {
|
||||
setEncryptedColumn(this, "token", value);
|
||||
}
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
@BelongsTo(() => Team, "teamId")
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
export default IntegrationAuthentication;
|
||||
|
||||
@@ -1,36 +1,55 @@
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
import {
|
||||
Table,
|
||||
ForeignKey,
|
||||
Model,
|
||||
Column,
|
||||
PrimaryKey,
|
||||
IsUUID,
|
||||
CreatedAt,
|
||||
BelongsTo,
|
||||
DataType,
|
||||
Default,
|
||||
} from "sequelize-typescript";
|
||||
import User from "./User";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const Notification = sequelize.define(
|
||||
"notification",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
event: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
updatedAt: false,
|
||||
}
|
||||
);
|
||||
@Table({
|
||||
tableName: "notifications",
|
||||
modelName: "notification",
|
||||
updatedAt: false,
|
||||
})
|
||||
@Fix
|
||||
class Notification extends Model {
|
||||
@IsUUID(4)
|
||||
@PrimaryKey
|
||||
@Default(DataType.UUIDV4)
|
||||
@Column(DataType.UUID)
|
||||
id: string;
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
Notification.associate = (models) => {
|
||||
Notification.belongsTo(models.User, {
|
||||
as: "actor",
|
||||
foreignKey: "actorId",
|
||||
});
|
||||
Notification.belongsTo(models.User, {
|
||||
as: "user",
|
||||
foreignKey: "userId",
|
||||
});
|
||||
};
|
||||
@CreatedAt
|
||||
createdAt: Date;
|
||||
|
||||
@Column
|
||||
event: string;
|
||||
|
||||
@Column
|
||||
email: boolean;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
@BelongsTo(() => User, "actorId")
|
||||
actor: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
actorId: string;
|
||||
}
|
||||
|
||||
export default Notification;
|
||||
|
||||
@@ -1,62 +1,81 @@
|
||||
import crypto from "crypto";
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
import {
|
||||
Table,
|
||||
ForeignKey,
|
||||
Model,
|
||||
Column,
|
||||
PrimaryKey,
|
||||
IsUUID,
|
||||
CreatedAt,
|
||||
BelongsTo,
|
||||
IsIn,
|
||||
Default,
|
||||
DataType,
|
||||
} from "sequelize-typescript";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const NotificationSetting = sequelize.define(
|
||||
"notification_setting",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
event: {
|
||||
type: DataTypes.STRING,
|
||||
validate: {
|
||||
isIn: [
|
||||
[
|
||||
"documents.publish",
|
||||
"documents.update",
|
||||
"collections.create",
|
||||
"emails.onboarding",
|
||||
"emails.features",
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
updatedAt: false,
|
||||
getterMethods: {
|
||||
unsubscribeUrl: function () {
|
||||
const token = NotificationSetting.getUnsubscribeToken(this.userId);
|
||||
return `${process.env.URL}/api/notificationSettings.unsubscribe?token=${token}&id=${this.id}`;
|
||||
},
|
||||
unsubscribeToken: function () {
|
||||
return NotificationSetting.getUnsubscribeToken(this.userId);
|
||||
},
|
||||
},
|
||||
@Table({
|
||||
tableName: "notification_settings",
|
||||
modelName: "notification_setting",
|
||||
updatedAt: false,
|
||||
})
|
||||
@Fix
|
||||
class NotificationSetting extends Model {
|
||||
@IsUUID(4)
|
||||
@PrimaryKey
|
||||
@Default(DataType.UUIDV4)
|
||||
@Column
|
||||
id: string;
|
||||
|
||||
@CreatedAt
|
||||
createdAt: Date;
|
||||
|
||||
@IsIn([
|
||||
[
|
||||
"documents.publish",
|
||||
"documents.update",
|
||||
"collections.create",
|
||||
"emails.onboarding",
|
||||
"emails.features",
|
||||
],
|
||||
])
|
||||
@Column(DataType.STRING)
|
||||
event: string;
|
||||
|
||||
// getters
|
||||
|
||||
get unsubscribeUrl() {
|
||||
const token = NotificationSetting.getUnsubscribeToken(this.userId);
|
||||
return `${process.env.URL}/api/notificationSettings.unsubscribe?token=${token}&id=${this.id}`;
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type.
|
||||
NotificationSetting.getUnsubscribeToken = (userId) => {
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(`${userId}-${process.env.SECRET_KEY}`);
|
||||
return hash.digest("hex");
|
||||
};
|
||||
get unsubscribeToken() {
|
||||
return NotificationSetting.getUnsubscribeToken(this.userId);
|
||||
}
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
NotificationSetting.associate = (models) => {
|
||||
NotificationSetting.belongsTo(models.User, {
|
||||
as: "user",
|
||||
foreignKey: "userId",
|
||||
onDelete: "cascade",
|
||||
});
|
||||
NotificationSetting.belongsTo(models.Team, {
|
||||
as: "team",
|
||||
foreignKey: "teamId",
|
||||
});
|
||||
};
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
@BelongsTo(() => Team, "teamId")
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
|
||||
static getUnsubscribeToken = (userId: string) => {
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(`${userId}-${process.env.SECRET_KEY}`);
|
||||
return hash.digest("hex");
|
||||
};
|
||||
}
|
||||
|
||||
export default NotificationSetting;
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
import {
|
||||
DataType,
|
||||
Column,
|
||||
ForeignKey,
|
||||
BelongsTo,
|
||||
Table,
|
||||
} from "sequelize-typescript";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import BaseModel from "./base/BaseModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
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,
|
||||
}
|
||||
);
|
||||
@Table({ tableName: "pins", modelName: "pin" })
|
||||
@Fix
|
||||
class Pin extends BaseModel {
|
||||
@Column
|
||||
index: string | null;
|
||||
|
||||
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",
|
||||
});
|
||||
};
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "createdById")
|
||||
createdBy: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
createdById: string;
|
||||
|
||||
@BelongsTo(() => Collection, "collectionId")
|
||||
collection: Collection;
|
||||
|
||||
@ForeignKey(() => Collection)
|
||||
@Column(DataType.UUID)
|
||||
collectionId: string;
|
||||
|
||||
@BelongsTo(() => Document, "documentId")
|
||||
document: Document;
|
||||
|
||||
@ForeignKey(() => Document)
|
||||
@Column(DataType.UUID)
|
||||
documentId: string;
|
||||
|
||||
@BelongsTo(() => Team, "teamId")
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
export default Pin;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Revision } from "@server/models";
|
||||
import { buildDocument } from "@server/test/factories";
|
||||
import { flushdb } from "@server/test/support";
|
||||
import Revision from "./Revision";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
beforeEach(jest.resetAllMocks);
|
||||
|
||||
describe("#findLatest", () => {
|
||||
test("should return latest revision", async () => {
|
||||
const document = await buildDocument({
|
||||
@@ -18,7 +19,7 @@ describe("#findLatest", () => {
|
||||
await document.save();
|
||||
await Revision.createFromDocument(document);
|
||||
const revision = await Revision.findLatest(document.id);
|
||||
expect(revision.title).toBe("Changed 2");
|
||||
expect(revision.text).toBe("Content");
|
||||
expect(revision?.title).toBe("Changed 2");
|
||||
expect(revision?.text).toBe("Content");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,103 +1,117 @@
|
||||
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'slat... Remove this comment to see the full error message
|
||||
import { FindOptions } from "sequelize";
|
||||
import {
|
||||
DataType,
|
||||
BelongsTo,
|
||||
Column,
|
||||
DefaultScope,
|
||||
ForeignKey,
|
||||
Table,
|
||||
} from "sequelize-typescript";
|
||||
import MarkdownSerializer from "slate-md-serializer";
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import BaseModel from "./base/BaseModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const serializer = new MarkdownSerializer();
|
||||
const Revision = sequelize.define("revision", {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
version: DataTypes.SMALLINT,
|
||||
editorVersion: DataTypes.STRING,
|
||||
title: DataTypes.STRING,
|
||||
text: DataTypes.TEXT,
|
||||
});
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
Revision.associate = (models) => {
|
||||
Revision.belongsTo(models.Document, {
|
||||
as: "document",
|
||||
foreignKey: "documentId",
|
||||
onDelete: "cascade",
|
||||
});
|
||||
Revision.belongsTo(models.User, {
|
||||
as: "user",
|
||||
foreignKey: "userId",
|
||||
});
|
||||
Revision.addScope(
|
||||
"defaultScope",
|
||||
@DefaultScope(() => ({
|
||||
include: [
|
||||
{
|
||||
include: [
|
||||
{
|
||||
model: models.User,
|
||||
as: "user",
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
model: User,
|
||||
as: "user",
|
||||
paranoid: false,
|
||||
},
|
||||
{
|
||||
override: true,
|
||||
],
|
||||
}))
|
||||
@Table({ tableName: "revisions", modelName: "revision" })
|
||||
@Fix
|
||||
class Revision extends BaseModel {
|
||||
@Column(DataType.SMALLINT)
|
||||
version: number;
|
||||
|
||||
@Column
|
||||
editorVersion: string;
|
||||
|
||||
@Column
|
||||
title: string;
|
||||
|
||||
@Column(DataType.TEXT)
|
||||
text: string;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => Document, "documentId")
|
||||
document: Document;
|
||||
|
||||
@ForeignKey(() => Document)
|
||||
@Column(DataType.UUID)
|
||||
documentId: string;
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
static findLatest(documentId: string) {
|
||||
return this.findOne({
|
||||
where: {
|
||||
documentId,
|
||||
},
|
||||
order: [["createdAt", "DESC"]],
|
||||
});
|
||||
}
|
||||
|
||||
static createFromDocument(
|
||||
document: Document,
|
||||
options?: FindOptions<Revision>
|
||||
) {
|
||||
return this.create(
|
||||
{
|
||||
title: document.title,
|
||||
text: document.text,
|
||||
userId: document.lastModifiedById,
|
||||
editorVersion: document.editorVersion,
|
||||
version: document.version,
|
||||
documentId: document.id,
|
||||
// revision time is set to the last time document was touched as this
|
||||
// handler can be debounced in the case of an update
|
||||
createdAt: document.updatedAt,
|
||||
},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
migrateVersion = function () {
|
||||
let migrated = false;
|
||||
|
||||
// migrate from document version 0 -> 1
|
||||
if (!this.version) {
|
||||
// removing the title from the document text attribute
|
||||
this.text = this.text.replace(/^#\s(.*)\n/, "");
|
||||
this.version = 1;
|
||||
migrated = true;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'documentId' implicitly has an 'any' typ... Remove this comment to see the full error message
|
||||
Revision.findLatest = function (documentId) {
|
||||
return Revision.findOne({
|
||||
where: {
|
||||
documentId,
|
||||
},
|
||||
order: [["createdAt", "DESC"]],
|
||||
});
|
||||
};
|
||||
// migrate from document version 1 -> 2
|
||||
if (this.version === 1) {
|
||||
const nodes = serializer.deserialize(this.text);
|
||||
this.text = serializer.serialize(nodes, {
|
||||
version: 2,
|
||||
});
|
||||
this.version = 2;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'document' implicitly has an 'any' type.
|
||||
Revision.createFromDocument = function (document, options) {
|
||||
return Revision.create(
|
||||
{
|
||||
title: document.title,
|
||||
text: document.text,
|
||||
userId: document.lastModifiedById,
|
||||
editorVersion: document.editorVersion,
|
||||
version: document.version,
|
||||
documentId: document.id,
|
||||
// revision time is set to the last time document was touched as this
|
||||
// handler can be debounced in the case of an update
|
||||
createdAt: document.updatedAt,
|
||||
},
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
Revision.prototype.migrateVersion = function () {
|
||||
let migrated = false;
|
||||
|
||||
// migrate from document version 0 -> 1
|
||||
if (!this.version) {
|
||||
// removing the title from the document text attribute
|
||||
this.text = this.text.replace(/^#\s(.*)\n/, "");
|
||||
this.version = 1;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
// migrate from document version 1 -> 2
|
||||
if (this.version === 1) {
|
||||
const nodes = serializer.deserialize(this.text);
|
||||
this.text = serializer.serialize(nodes, {
|
||||
version: 2,
|
||||
});
|
||||
this.version = 2;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (migrated) {
|
||||
return this.save({
|
||||
silent: true,
|
||||
hooks: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
if (migrated) {
|
||||
return this.save({
|
||||
silent: true,
|
||||
hooks: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default Revision;
|
||||
|
||||
@@ -1,48 +1,65 @@
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
import {
|
||||
Table,
|
||||
ForeignKey,
|
||||
Model,
|
||||
Column,
|
||||
PrimaryKey,
|
||||
IsUUID,
|
||||
CreatedAt,
|
||||
BelongsTo,
|
||||
DataType,
|
||||
Default,
|
||||
} from "sequelize-typescript";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const SearchQuery = sequelize.define(
|
||||
"search_queries",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
source: {
|
||||
type: DataTypes.ENUM("slack", "app", "api"),
|
||||
allowNull: false,
|
||||
},
|
||||
query: {
|
||||
type: DataTypes.STRING,
|
||||
@Table({
|
||||
tableName: "search_queries",
|
||||
modelName: "search_query",
|
||||
updatedAt: false,
|
||||
})
|
||||
@Fix
|
||||
class SearchQuery extends Model {
|
||||
@IsUUID(4)
|
||||
@PrimaryKey
|
||||
@Default(DataType.UUIDV4)
|
||||
@Column(DataType.UUID)
|
||||
id: string;
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'val' implicitly has an 'any' type.
|
||||
set(val) {
|
||||
this.setDataValue("query", val.substring(0, 255));
|
||||
},
|
||||
@CreatedAt
|
||||
createdAt: Date;
|
||||
|
||||
allowNull: false,
|
||||
},
|
||||
results: {
|
||||
type: DataTypes.NUMBER,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
updatedAt: false,
|
||||
@Column(DataType.ENUM("slack", "app", "api"))
|
||||
source: string;
|
||||
|
||||
@Column
|
||||
results: number;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
set query(value: string) {
|
||||
this.setDataValue("query", value.substring(0, 255));
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
SearchQuery.associate = (models) => {
|
||||
SearchQuery.belongsTo(models.User, {
|
||||
as: "user",
|
||||
foreignKey: "userId",
|
||||
});
|
||||
SearchQuery.belongsTo(models.Team, {
|
||||
as: "team",
|
||||
foreignKey: "teamId",
|
||||
});
|
||||
};
|
||||
get query() {
|
||||
return this.getDataValue("query");
|
||||
}
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
@BelongsTo(() => Team, "teamId")
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
export default SearchQuery;
|
||||
|
||||
@@ -1,67 +1,45 @@
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
import {
|
||||
ForeignKey,
|
||||
BelongsTo,
|
||||
Column,
|
||||
DefaultScope,
|
||||
Table,
|
||||
Scopes,
|
||||
DataType,
|
||||
} from "sequelize-typescript";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import BaseModel from "./base/BaseModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const Share = sequelize.define(
|
||||
"share",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
@DefaultScope(() => ({
|
||||
include: [
|
||||
{
|
||||
association: "user",
|
||||
paranoid: false,
|
||||
},
|
||||
published: DataTypes.BOOLEAN,
|
||||
includeChildDocuments: DataTypes.BOOLEAN,
|
||||
revokedAt: DataTypes.DATE,
|
||||
revokedById: DataTypes.UUID,
|
||||
lastAccessedAt: DataTypes.DATE,
|
||||
},
|
||||
{
|
||||
getterMethods: {
|
||||
isRevoked() {
|
||||
return !!this.revokedAt;
|
||||
},
|
||||
{
|
||||
association: "document",
|
||||
required: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
Share.associate = (models) => {
|
||||
Share.belongsTo(models.User, {
|
||||
as: "user",
|
||||
foreignKey: "userId",
|
||||
});
|
||||
Share.belongsTo(models.Team, {
|
||||
as: "team",
|
||||
foreignKey: "teamId",
|
||||
});
|
||||
Share.belongsTo(models.Document.scope("withUnpublished"), {
|
||||
as: "document",
|
||||
foreignKey: "documentId",
|
||||
});
|
||||
Share.addScope("defaultScope", {
|
||||
include: [
|
||||
{
|
||||
association: "user",
|
||||
paranoid: false,
|
||||
},
|
||||
{
|
||||
association: "document",
|
||||
},
|
||||
{
|
||||
association: "team",
|
||||
},
|
||||
],
|
||||
});
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type.
|
||||
Share.addScope("withCollection", (userId) => {
|
||||
{
|
||||
association: "team",
|
||||
},
|
||||
],
|
||||
}))
|
||||
@Scopes(() => ({
|
||||
withCollection: (userId: string) => {
|
||||
return {
|
||||
include: [
|
||||
{
|
||||
model: models.Document,
|
||||
model: Document,
|
||||
paranoid: true,
|
||||
as: "document",
|
||||
include: [
|
||||
{
|
||||
model: models.Collection.scope({
|
||||
model: Collection.scope({
|
||||
method: ["withMembership", userId],
|
||||
}),
|
||||
as: "collection",
|
||||
@@ -77,14 +55,64 @@ Share.associate = (models) => {
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
};
|
||||
},
|
||||
}))
|
||||
@Table({ tableName: "shares", modelName: "share" })
|
||||
@Fix
|
||||
class Share extends BaseModel {
|
||||
@Column
|
||||
published: boolean;
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type.
|
||||
Share.prototype.revoke = function (userId) {
|
||||
this.revokedAt = new Date();
|
||||
this.revokedById = userId;
|
||||
return this.save();
|
||||
};
|
||||
@Column
|
||||
includeChildDocuments: boolean;
|
||||
|
||||
@Column
|
||||
revokedAt: Date | null;
|
||||
|
||||
@Column
|
||||
lastAccessedAt: Date | null;
|
||||
|
||||
// getters
|
||||
|
||||
get isRevoked() {
|
||||
return !!this.revokedAt;
|
||||
}
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "revokedById")
|
||||
revokedBy: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
revokedById: string;
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
@BelongsTo(() => Team, "teamId")
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
|
||||
@BelongsTo(() => Document, "documentId")
|
||||
document: Document;
|
||||
|
||||
@ForeignKey(() => Document)
|
||||
@Column(DataType.UUID)
|
||||
documentId: string;
|
||||
|
||||
revoke(userId: string) {
|
||||
this.revokedAt = new Date();
|
||||
this.revokedById = userId;
|
||||
return this.save();
|
||||
}
|
||||
}
|
||||
|
||||
export default Share;
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
import {
|
||||
Column,
|
||||
DataType,
|
||||
BelongsTo,
|
||||
ForeignKey,
|
||||
Table,
|
||||
} from "sequelize-typescript";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import BaseModel from "./base/BaseModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const Star = sequelize.define("star", {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
});
|
||||
@Table({ tableName: "stars", modelName: "star" })
|
||||
@Fix
|
||||
class Star extends BaseModel {
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
Star.associate = (models) => {
|
||||
Star.belongsTo(models.Document);
|
||||
Star.belongsTo(models.User);
|
||||
};
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
@BelongsTo(() => Document, "documentId")
|
||||
document: Document;
|
||||
|
||||
@ForeignKey(() => Document)
|
||||
@Column(DataType.UUID)
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
export default Star;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { buildTeam, buildCollection } from "@server/test/factories";
|
||||
import { flushdb } from "@server/test/support";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("collectionIds", () => {
|
||||
it("should return non-private collection ids", async () => {
|
||||
const team = await buildTeam();
|
||||
@@ -20,6 +21,7 @@ describe("collectionIds", () => {
|
||||
expect(response[0]).toEqual(collection.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provisionSubdomain", () => {
|
||||
it("should set subdomain if available", async () => {
|
||||
const team = await buildTeam();
|
||||
|
||||
@@ -2,258 +2,246 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import { URL } from "url";
|
||||
import util from "util";
|
||||
import { Op } from "sequelize";
|
||||
import {
|
||||
Column,
|
||||
IsLowercase,
|
||||
NotIn,
|
||||
Default,
|
||||
Table,
|
||||
Unique,
|
||||
IsIn,
|
||||
BeforeSave,
|
||||
HasMany,
|
||||
Scopes,
|
||||
Length,
|
||||
Is,
|
||||
DataType,
|
||||
} from "sequelize-typescript";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { stripSubdomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains";
|
||||
import Logger from "@server/logging/logger";
|
||||
import { generateAvatarUrl } from "@server/utils/avatars";
|
||||
import { publicS3Endpoint, uploadToS3FromUrl } from "@server/utils/s3";
|
||||
import { DataTypes, sequelize, Op } from "../sequelize";
|
||||
import AuthenticationProvider from "./AuthenticationProvider";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const readFile = util.promisify(fs.readFile);
|
||||
|
||||
const Team = sequelize.define(
|
||||
"team",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
subdomain: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
validate: {
|
||||
isLowercase: true,
|
||||
is: {
|
||||
args: [/^[a-z\d-]+$/, "i"],
|
||||
msg: "Must be only alphanumeric and dashes",
|
||||
},
|
||||
len: {
|
||||
args: [4, 32],
|
||||
msg: "Must be between 4 and 32 characters",
|
||||
},
|
||||
notIn: {
|
||||
args: [RESERVED_SUBDOMAINS],
|
||||
msg: "You chose a restricted word, please try another.",
|
||||
},
|
||||
},
|
||||
unique: true,
|
||||
},
|
||||
domain: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
unique: true,
|
||||
},
|
||||
slackId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
googleId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
avatarUrl: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
sharing: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
signupQueryParams: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
},
|
||||
guestSignin: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
documentEmbeds: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
collaborativeEditing: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
defaultUserRole: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: "member",
|
||||
allowNull: false,
|
||||
validate: {
|
||||
isIn: [["viewer", "member"]],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
paranoid: true,
|
||||
getterMethods: {
|
||||
url() {
|
||||
if (this.domain) {
|
||||
return `https://${this.domain}`;
|
||||
}
|
||||
|
||||
if (!this.subdomain || process.env.SUBDOMAINS_ENABLED !== "true") {
|
||||
return process.env.URL;
|
||||
}
|
||||
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | undefined' is not assig... Remove this comment to see the full error message
|
||||
const url = new URL(process.env.URL);
|
||||
url.host = `${this.subdomain}.${stripSubdomain(url.host)}`;
|
||||
return url.href.replace(/\/$/, "");
|
||||
},
|
||||
|
||||
logoUrl() {
|
||||
return (
|
||||
this.avatarUrl ||
|
||||
generateAvatarUrl({
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
Team.associate = (models) => {
|
||||
Team.hasMany(models.Collection, {
|
||||
as: "collections",
|
||||
});
|
||||
Team.hasMany(models.Document, {
|
||||
as: "documents",
|
||||
});
|
||||
Team.hasMany(models.User, {
|
||||
as: "users",
|
||||
});
|
||||
Team.hasMany(models.AuthenticationProvider, {
|
||||
as: "authenticationProviders",
|
||||
});
|
||||
Team.addScope("withAuthenticationProviders", {
|
||||
@Scopes(() => ({
|
||||
withAuthenticationProviders: {
|
||||
include: [
|
||||
{
|
||||
model: models.AuthenticationProvider,
|
||||
model: AuthenticationProvider,
|
||||
as: "authenticationProviders",
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
},
|
||||
}))
|
||||
@Table({ tableName: "teams", modelName: "team" })
|
||||
@Fix
|
||||
class Team extends ParanoidModel {
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'model' implicitly has an 'any' type.
|
||||
const uploadAvatar = async (model) => {
|
||||
const endpoint = publicS3Endpoint();
|
||||
const { avatarUrl } = model;
|
||||
@IsLowercase
|
||||
@Unique
|
||||
@Length({ min: 4, max: 32, msg: "Must be between 4 and 32 characters" })
|
||||
@Is({
|
||||
args: [/^[a-z\d-]+$/, "i"],
|
||||
msg: "Must be only alphanumeric and dashes",
|
||||
})
|
||||
@NotIn({
|
||||
args: [RESERVED_SUBDOMAINS],
|
||||
msg: "You chose a restricted word, please try another.",
|
||||
})
|
||||
@Column
|
||||
subdomain: string | null;
|
||||
|
||||
if (
|
||||
avatarUrl &&
|
||||
!avatarUrl.startsWith("/api") &&
|
||||
!avatarUrl.startsWith(endpoint)
|
||||
) {
|
||||
try {
|
||||
const newUrl = await uploadToS3FromUrl(
|
||||
avatarUrl,
|
||||
`avatars/${model.id}/${uuidv4()}`,
|
||||
"public-read"
|
||||
);
|
||||
if (newUrl) model.avatarUrl = newUrl;
|
||||
} catch (err) {
|
||||
Logger.error("Error uploading avatar to S3", err, {
|
||||
url: avatarUrl,
|
||||
});
|
||||
@Unique
|
||||
@Column
|
||||
domain: string | null;
|
||||
|
||||
@Column
|
||||
avatarUrl: string | null;
|
||||
|
||||
@Default(true)
|
||||
@Column
|
||||
sharing: boolean;
|
||||
|
||||
@Default(true)
|
||||
@Column(DataType.JSONB)
|
||||
signupQueryParams: { [key: string]: string } | null;
|
||||
|
||||
@Default(true)
|
||||
@Column
|
||||
guestSignin: boolean;
|
||||
|
||||
@Default(true)
|
||||
@Column
|
||||
documentEmbeds: boolean;
|
||||
|
||||
@Default(false)
|
||||
@Column
|
||||
collaborativeEditing: boolean;
|
||||
|
||||
@Default("member")
|
||||
@IsIn([["viewer", "member"]])
|
||||
@Column
|
||||
defaultUserRole: string;
|
||||
|
||||
// getters
|
||||
|
||||
get url() {
|
||||
if (this.domain) {
|
||||
return `https://${this.domain}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Team.prototype.provisionSubdomain = async function (
|
||||
requestedSubdomain: string,
|
||||
options = {}
|
||||
) {
|
||||
if (this.subdomain) return this.subdomain;
|
||||
let subdomain = requestedSubdomain;
|
||||
let append = 0;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
await this.update(
|
||||
{
|
||||
subdomain,
|
||||
},
|
||||
options
|
||||
);
|
||||
break;
|
||||
} catch (err) {
|
||||
// subdomain was invalid or already used, try again
|
||||
subdomain = `${requestedSubdomain}${++append}`;
|
||||
if (!this.subdomain || process.env.SUBDOMAINS_ENABLED !== "true") {
|
||||
return process.env.URL;
|
||||
}
|
||||
|
||||
const url = new URL(process.env.URL || "");
|
||||
url.host = `${this.subdomain}.${stripSubdomain(url.host)}`;
|
||||
return url.href.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
return subdomain;
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type.
|
||||
Team.prototype.provisionFirstCollection = async function (userId) {
|
||||
const collection = await Collection.create({
|
||||
name: "Welcome",
|
||||
description:
|
||||
"This collection is a quick guide to what Outline is all about. Feel free to delete this collection once your team is up to speed with the basics!",
|
||||
teamId: this.id,
|
||||
createdById: userId,
|
||||
sort: Collection.DEFAULT_SORT,
|
||||
permission: "read_write",
|
||||
});
|
||||
// For the first collection we go ahead and create some intitial documents to get
|
||||
// the team started. You can edit these in /server/onboarding/x.md
|
||||
const onboardingDocs = [
|
||||
"Integrations & API",
|
||||
"Our Editor",
|
||||
"Getting Started",
|
||||
"What is Outline",
|
||||
];
|
||||
|
||||
for (const title of onboardingDocs) {
|
||||
const text = await readFile(
|
||||
path.join(process.cwd(), "server", "onboarding", `${title}.md`),
|
||||
"utf8"
|
||||
get logoUrl() {
|
||||
return (
|
||||
this.avatarUrl ||
|
||||
generateAvatarUrl({
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
})
|
||||
);
|
||||
const document = await Document.create({
|
||||
version: 2,
|
||||
isWelcome: true,
|
||||
parentDocumentId: null,
|
||||
collectionId: collection.id,
|
||||
teamId: collection.teamId,
|
||||
userId: collection.createdById,
|
||||
lastModifiedById: collection.createdById,
|
||||
createdById: collection.createdById,
|
||||
title,
|
||||
text,
|
||||
});
|
||||
await document.publish(collection.createdById);
|
||||
}
|
||||
};
|
||||
|
||||
Team.prototype.collectionIds = async function (paranoid = true) {
|
||||
const models = await Collection.findAll({
|
||||
attributes: ["id"],
|
||||
where: {
|
||||
// TODO: Move to command
|
||||
provisionSubdomain = async function (
|
||||
requestedSubdomain: string,
|
||||
options = {}
|
||||
) {
|
||||
if (this.subdomain) return this.subdomain;
|
||||
let subdomain = requestedSubdomain;
|
||||
let append = 0;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
await this.update(
|
||||
{
|
||||
subdomain,
|
||||
},
|
||||
options
|
||||
);
|
||||
break;
|
||||
} catch (err) {
|
||||
// subdomain was invalid or already used, try again
|
||||
subdomain = `${requestedSubdomain}${++append}`;
|
||||
}
|
||||
}
|
||||
|
||||
return subdomain;
|
||||
};
|
||||
|
||||
provisionFirstCollection = async function (userId: string) {
|
||||
const collection = await Collection.create({
|
||||
name: "Welcome",
|
||||
description:
|
||||
"This collection is a quick guide to what Outline is all about. Feel free to delete this collection once your team is up to speed with the basics!",
|
||||
teamId: this.id,
|
||||
permission: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
paranoid,
|
||||
});
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'c' implicitly has an 'any' type.
|
||||
return models.map((c) => c.id);
|
||||
};
|
||||
createdById: userId,
|
||||
sort: Collection.DEFAULT_SORT,
|
||||
permission: "read_write",
|
||||
});
|
||||
|
||||
Team.beforeSave(uploadAvatar);
|
||||
// For the first collection we go ahead and create some intitial documents to get
|
||||
// the team started. You can edit these in /server/onboarding/x.md
|
||||
const onboardingDocs = [
|
||||
"Integrations & API",
|
||||
"Our Editor",
|
||||
"Getting Started",
|
||||
"What is Outline",
|
||||
];
|
||||
|
||||
for (const title of onboardingDocs) {
|
||||
const text = await readFile(
|
||||
path.join(process.cwd(), "server", "onboarding", `${title}.md`),
|
||||
"utf8"
|
||||
);
|
||||
const document = await Document.create({
|
||||
version: 2,
|
||||
isWelcome: true,
|
||||
parentDocumentId: null,
|
||||
collectionId: collection.id,
|
||||
teamId: collection.teamId,
|
||||
userId: collection.createdById,
|
||||
lastModifiedById: collection.createdById,
|
||||
createdById: collection.createdById,
|
||||
title,
|
||||
text,
|
||||
});
|
||||
await document.publish(collection.createdById);
|
||||
}
|
||||
};
|
||||
|
||||
collectionIds = async function (paranoid = true) {
|
||||
const models = await Collection.findAll({
|
||||
attributes: ["id"],
|
||||
where: {
|
||||
teamId: this.id,
|
||||
permission: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
paranoid,
|
||||
});
|
||||
return models.map((c) => c.id);
|
||||
};
|
||||
|
||||
// associations
|
||||
|
||||
@HasMany(() => Collection)
|
||||
collections: Collection[];
|
||||
|
||||
@HasMany(() => Document)
|
||||
documents: Document[];
|
||||
|
||||
@HasMany(() => User)
|
||||
users: User[];
|
||||
|
||||
@HasMany(() => AuthenticationProvider)
|
||||
authenticationProviders: AuthenticationProvider[];
|
||||
|
||||
// hooks
|
||||
|
||||
@BeforeSave
|
||||
static uploadAvatar = async (model: Team) => {
|
||||
const endpoint = publicS3Endpoint();
|
||||
const { avatarUrl } = model;
|
||||
|
||||
if (
|
||||
avatarUrl &&
|
||||
!avatarUrl.startsWith("/api") &&
|
||||
!avatarUrl.startsWith(endpoint)
|
||||
) {
|
||||
try {
|
||||
const newUrl = await uploadToS3FromUrl(
|
||||
avatarUrl,
|
||||
`avatars/${model.id}/${uuidv4()}`,
|
||||
"public-read"
|
||||
);
|
||||
if (newUrl) model.avatarUrl = newUrl;
|
||||
} catch (err) {
|
||||
Logger.error("Error uploading avatar to S3", err, {
|
||||
url: avatarUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default Team;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { UserAuthentication, CollectionUser } from "@server/models";
|
||||
import { buildUser, buildTeam, buildCollection } from "@server/test/factories";
|
||||
import { flushdb } from "@server/test/support";
|
||||
import CollectionUser from "./CollectionUser";
|
||||
import UserAuthentication from "./UserAuthentication";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("user model", () => {
|
||||
describe("destroy", () => {
|
||||
it("should delete user authentications", async () => {
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import crypto from "crypto";
|
||||
import { addMinutes, subMinutes } from "date-fns";
|
||||
import JWT from "jsonwebtoken";
|
||||
import { Transaction, QueryTypes, FindOptions, Op } from "sequelize";
|
||||
import {
|
||||
Table,
|
||||
Column,
|
||||
IsIP,
|
||||
IsEmail,
|
||||
HasOne,
|
||||
Default,
|
||||
IsIn,
|
||||
BeforeDestroy,
|
||||
BeforeSave,
|
||||
BeforeCreate,
|
||||
AfterCreate,
|
||||
BelongsTo,
|
||||
ForeignKey,
|
||||
DataType,
|
||||
HasMany,
|
||||
Scopes,
|
||||
} from "sequelize-typescript";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { languages } from "@shared/i18n";
|
||||
import Logger from "@server/logging/logger";
|
||||
@@ -8,427 +27,441 @@ import { DEFAULT_AVATAR_HOST } from "@server/utils/avatars";
|
||||
import { palette } from "@server/utils/color";
|
||||
import { publicS3Endpoint, uploadToS3FromUrl } from "@server/utils/s3";
|
||||
import { ValidationError } from "../errors";
|
||||
import { DataTypes, sequelize, encryptedFields, Op } from "../sequelize";
|
||||
import {
|
||||
UserAuthentication,
|
||||
Star,
|
||||
Collection,
|
||||
NotificationSetting,
|
||||
ApiKey,
|
||||
} from ".";
|
||||
import ApiKey from "./ApiKey";
|
||||
import Collection from "./Collection";
|
||||
import NotificationSetting from "./NotificationSetting";
|
||||
import Star from "./Star";
|
||||
import Team from "./Team";
|
||||
import UserAuthentication from "./UserAuthentication";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Encrypted, {
|
||||
setEncryptedColumn,
|
||||
getEncryptedColumn,
|
||||
} from "./decorators/Encrypted";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const User = sequelize.define(
|
||||
"user",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
avatarUrl: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
isAdmin: DataTypes.BOOLEAN,
|
||||
isViewer: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
allowNull: false,
|
||||
},
|
||||
service: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
serviceId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
unique: true,
|
||||
},
|
||||
jwtSecret: encryptedFields().vault("jwtSecret"),
|
||||
lastActiveAt: DataTypes.DATE,
|
||||
lastActiveIp: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
lastSignedInAt: DataTypes.DATE,
|
||||
lastSignedInIp: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
lastSigninEmailSentAt: DataTypes.DATE,
|
||||
suspendedAt: DataTypes.DATE,
|
||||
suspendedById: DataTypes.UUID,
|
||||
language: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: process.env.DEFAULT_LANGUAGE,
|
||||
validate: {
|
||||
isIn: [languages],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
paranoid: true,
|
||||
getterMethods: {
|
||||
isSuspended() {
|
||||
return !!this.suspendedAt;
|
||||
},
|
||||
|
||||
isInvited() {
|
||||
return !this.lastActiveAt;
|
||||
},
|
||||
|
||||
avatarUrl() {
|
||||
const original = this.getDataValue("avatarUrl");
|
||||
|
||||
if (original) {
|
||||
return original;
|
||||
}
|
||||
|
||||
const color = this.color.replace(/^#/, "");
|
||||
const initial = this.name ? this.name[0] : "?";
|
||||
const hash = crypto
|
||||
.createHash("md5")
|
||||
.update(this.email || "")
|
||||
.digest("hex");
|
||||
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png?c=${color}`;
|
||||
},
|
||||
|
||||
color() {
|
||||
const idAsHex = crypto.createHash("md5").update(this.id).digest("hex");
|
||||
const idAsNumber = parseInt(idAsHex, 16);
|
||||
return palette[idAsNumber % palette.length];
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Class methods
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
User.associate = (models) => {
|
||||
User.hasMany(models.ApiKey, {
|
||||
as: "apiKeys",
|
||||
onDelete: "cascade",
|
||||
});
|
||||
User.hasMany(models.NotificationSetting, {
|
||||
as: "notificationSettings",
|
||||
onDelete: "cascade",
|
||||
});
|
||||
User.hasMany(models.Document, {
|
||||
as: "documents",
|
||||
});
|
||||
User.hasMany(models.View, {
|
||||
as: "views",
|
||||
});
|
||||
User.hasMany(models.UserAuthentication, {
|
||||
as: "authentications",
|
||||
});
|
||||
User.belongsTo(models.Team);
|
||||
User.addScope("withAuthentications", {
|
||||
@Scopes(() => ({
|
||||
withAuthentications: {
|
||||
include: [
|
||||
{
|
||||
model: models.UserAuthentication,
|
||||
model: UserAuthentication,
|
||||
as: "authentications",
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
},
|
||||
}))
|
||||
@Table({ tableName: "users", modelName: "user" })
|
||||
@Fix
|
||||
class User extends ParanoidModel {
|
||||
@IsEmail
|
||||
@Column
|
||||
email: string | null;
|
||||
|
||||
// Instance methods
|
||||
User.prototype.collectionIds = async function (options = {}) {
|
||||
const collectionStubs = await Collection.scope({
|
||||
method: ["withMembership", this.id],
|
||||
}).findAll({
|
||||
attributes: ["id", "permission"],
|
||||
where: {
|
||||
teamId: this.teamId,
|
||||
},
|
||||
paranoid: true,
|
||||
...options,
|
||||
});
|
||||
return (
|
||||
collectionStubs
|
||||
@Column
|
||||
username: string | null;
|
||||
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
@Default(false)
|
||||
@Column
|
||||
isAdmin: boolean;
|
||||
|
||||
@Default(false)
|
||||
@Column
|
||||
isViewer: boolean;
|
||||
|
||||
@Column(DataType.BLOB)
|
||||
@Encrypted
|
||||
get jwtSecret() {
|
||||
return getEncryptedColumn(this, "jwtSecret");
|
||||
}
|
||||
|
||||
set jwtSecret(value: string) {
|
||||
setEncryptedColumn(this, "jwtSecret", value);
|
||||
}
|
||||
|
||||
@Column
|
||||
lastActiveAt: Date | null;
|
||||
|
||||
@IsIP
|
||||
@Column
|
||||
lastActiveIp: string | null;
|
||||
|
||||
@Column
|
||||
lastSignedInAt: Date | null;
|
||||
|
||||
@IsIP
|
||||
@Column
|
||||
lastSignedInIp: string | null;
|
||||
|
||||
@Column
|
||||
lastSigninEmailSentAt: Date | null;
|
||||
|
||||
@Column
|
||||
suspendedAt: Date | null;
|
||||
|
||||
@Default(process.env.DEFAULT_LANGUAGE)
|
||||
@IsIn([languages])
|
||||
@Column
|
||||
language: string;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
get avatarUrl() {
|
||||
const original = this.getDataValue("avatarUrl");
|
||||
|
||||
if (original) {
|
||||
return original;
|
||||
}
|
||||
|
||||
const color = this.color.replace(/^#/, "");
|
||||
const initial = this.name ? this.name[0] : "?";
|
||||
const hash = crypto
|
||||
.createHash("md5")
|
||||
.update(this.email || "")
|
||||
.digest("hex");
|
||||
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png?c=${color}`;
|
||||
}
|
||||
|
||||
set avatarUrl(value: string | null) {
|
||||
this.setDataValue("avatarUrl", value);
|
||||
}
|
||||
|
||||
// associations
|
||||
|
||||
@HasOne(() => User, "suspendedById")
|
||||
suspendedBy: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
suspendedById: string;
|
||||
|
||||
@BelongsTo(() => Team)
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
|
||||
@HasMany(() => UserAuthentication)
|
||||
authentications: UserAuthentication[];
|
||||
|
||||
// getters
|
||||
|
||||
get isSuspended(): boolean {
|
||||
return !!this.suspendedAt;
|
||||
}
|
||||
|
||||
get isInvited() {
|
||||
return !this.lastActiveAt;
|
||||
}
|
||||
|
||||
get color() {
|
||||
const idAsHex = crypto.createHash("md5").update(this.id).digest("hex");
|
||||
const idAsNumber = parseInt(idAsHex, 16);
|
||||
return palette[idAsNumber % palette.length];
|
||||
}
|
||||
|
||||
// instance methods
|
||||
|
||||
collectionIds = async (options = {}) => {
|
||||
const collectionStubs = await Collection.scope({
|
||||
method: ["withMembership", this.id],
|
||||
}).findAll({
|
||||
attributes: ["id", "permission"],
|
||||
where: {
|
||||
teamId: this.teamId,
|
||||
},
|
||||
paranoid: true,
|
||||
...options,
|
||||
});
|
||||
|
||||
return collectionStubs
|
||||
.filter(
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'c' implicitly has an 'any' type.
|
||||
(c) =>
|
||||
c.permission === "read" ||
|
||||
c.permission === "read_write" ||
|
||||
c.memberships.length > 0 ||
|
||||
c.collectionGroupMemberships.length > 0
|
||||
)
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'c' implicitly has an 'any' type.
|
||||
.map((c) => c.id)
|
||||
);
|
||||
};
|
||||
.map((c) => c.id);
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ip' implicitly has an 'any' type.
|
||||
User.prototype.updateActiveAt = function (ip, force = false) {
|
||||
const fiveMinutesAgo = subMinutes(new Date(), 5);
|
||||
updateActiveAt = (ip: string, force = false) => {
|
||||
const fiveMinutesAgo = subMinutes(new Date(), 5);
|
||||
|
||||
// ensure this is updated only every few minutes otherwise
|
||||
// we'll be constantly writing to the DB as API requests happen
|
||||
if (this.lastActiveAt < fiveMinutesAgo || force) {
|
||||
this.lastActiveAt = new Date();
|
||||
this.lastActiveIp = ip;
|
||||
// ensure this is updated only every few minutes otherwise
|
||||
// we'll be constantly writing to the DB as API requests happen
|
||||
if (!this.lastActiveAt || this.lastActiveAt < fiveMinutesAgo || force) {
|
||||
this.lastActiveAt = new Date();
|
||||
this.lastActiveIp = ip;
|
||||
|
||||
return this.save({
|
||||
hooks: false,
|
||||
});
|
||||
}
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
updateSignedIn = (ip: string) => {
|
||||
this.lastSignedInAt = new Date();
|
||||
this.lastSignedInIp = ip;
|
||||
return this.save({
|
||||
hooks: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ip' implicitly has an 'any' type.
|
||||
User.prototype.updateSignedIn = function (ip) {
|
||||
this.lastSignedInAt = new Date();
|
||||
this.lastSignedInIp = ip;
|
||||
return this.save({
|
||||
hooks: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Returns a session token that is used to make API requests and is stored
|
||||
// in the client browser cookies to remain logged in.
|
||||
User.prototype.getJwtToken = function (expiresAt?: Date) {
|
||||
return JWT.sign(
|
||||
{
|
||||
id: this.id,
|
||||
expiresAt: expiresAt ? expiresAt.toISOString() : undefined,
|
||||
type: "session",
|
||||
},
|
||||
this.jwtSecret
|
||||
);
|
||||
};
|
||||
|
||||
// Returns a temporary token that is only used for transferring a session
|
||||
// between subdomains or domains. It has a short expiry and can only be used once
|
||||
User.prototype.getTransferToken = function () {
|
||||
return JWT.sign(
|
||||
{
|
||||
id: this.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: addMinutes(new Date(), 1).toISOString(),
|
||||
type: "transfer",
|
||||
},
|
||||
this.jwtSecret
|
||||
);
|
||||
};
|
||||
|
||||
// Returns a temporary token that is only used for logging in from an email
|
||||
// It can only be used to sign in once and has a medium length expiry
|
||||
User.prototype.getEmailSigninToken = function () {
|
||||
return JWT.sign(
|
||||
{
|
||||
id: this.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
type: "email-signin",
|
||||
},
|
||||
this.jwtSecret
|
||||
);
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'model' implicitly has an 'any' type.
|
||||
const uploadAvatar = async (model) => {
|
||||
const endpoint = publicS3Endpoint();
|
||||
const { avatarUrl } = model;
|
||||
|
||||
if (
|
||||
avatarUrl &&
|
||||
!avatarUrl.startsWith("/api") &&
|
||||
!avatarUrl.startsWith(endpoint) &&
|
||||
!avatarUrl.startsWith(DEFAULT_AVATAR_HOST)
|
||||
) {
|
||||
try {
|
||||
const newUrl = await uploadToS3FromUrl(
|
||||
avatarUrl,
|
||||
`avatars/${model.id}/${uuidv4()}`,
|
||||
"public-read"
|
||||
);
|
||||
if (newUrl) model.avatarUrl = newUrl;
|
||||
} catch (err) {
|
||||
Logger.error("Couldn't upload user avatar image to S3", err, {
|
||||
url: avatarUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'model' implicitly has an 'any' type.
|
||||
const setRandomJwtSecret = (model) => {
|
||||
model.jwtSecret = crypto.randomBytes(64).toString("hex");
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'model' implicitly has an 'any' type.
|
||||
const removeIdentifyingInfo = async (model, options) => {
|
||||
await NotificationSetting.destroy({
|
||||
where: {
|
||||
userId: model.id,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
await ApiKey.destroy({
|
||||
where: {
|
||||
userId: model.id,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
await Star.destroy({
|
||||
where: {
|
||||
userId: model.id,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
await UserAuthentication.destroy({
|
||||
where: {
|
||||
userId: model.id,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
model.email = null;
|
||||
model.name = "Unknown";
|
||||
model.avatarUrl = "";
|
||||
model.serviceId = null;
|
||||
model.username = null;
|
||||
model.lastActiveIp = null;
|
||||
model.lastSignedInIp = null;
|
||||
// this shouldn't be needed once this issue is resolved:
|
||||
// https://github.com/sequelize/sequelize/issues/9318
|
||||
await model.save({
|
||||
hooks: false,
|
||||
transaction: options.transaction,
|
||||
});
|
||||
};
|
||||
|
||||
User.beforeDestroy(removeIdentifyingInfo);
|
||||
User.beforeSave(uploadAvatar);
|
||||
User.beforeCreate(setRandomJwtSecret);
|
||||
// By default when a user signs up we subscribe them to email notifications
|
||||
// when documents they created are edited by other team members and onboarding
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'user' implicitly has an 'any' type.
|
||||
User.afterCreate(async (user, options) => {
|
||||
await Promise.all([
|
||||
NotificationSetting.findOrCreate({
|
||||
where: {
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
event: "documents.update",
|
||||
},
|
||||
transaction: options.transaction,
|
||||
}),
|
||||
NotificationSetting.findOrCreate({
|
||||
where: {
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
event: "emails.onboarding",
|
||||
},
|
||||
transaction: options.transaction,
|
||||
}),
|
||||
NotificationSetting.findOrCreate({
|
||||
where: {
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
event: "emails.features",
|
||||
},
|
||||
transaction: options.transaction,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
User.getCounts = async function (teamId: string) {
|
||||
const countSql = `
|
||||
SELECT
|
||||
COUNT(CASE WHEN "suspendedAt" IS NOT NULL THEN 1 END) as "suspendedCount",
|
||||
COUNT(CASE WHEN "isAdmin" = true THEN 1 END) as "adminCount",
|
||||
COUNT(CASE WHEN "isViewer" = true THEN 1 END) as "viewerCount",
|
||||
COUNT(CASE WHEN "lastActiveAt" IS NULL THEN 1 END) as "invitedCount",
|
||||
COUNT(CASE WHEN "suspendedAt" IS NULL AND "lastActiveAt" IS NOT NULL THEN 1 END) as "activeCount",
|
||||
COUNT(*) as count
|
||||
FROM users
|
||||
WHERE "deletedAt" IS NULL
|
||||
AND "teamId" = :teamId
|
||||
`;
|
||||
const results = await sequelize.query(countSql, {
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
const counts = results[0];
|
||||
return {
|
||||
active: parseInt(counts.activeCount),
|
||||
admins: parseInt(counts.adminCount),
|
||||
viewers: parseInt(counts.viewerCount),
|
||||
all: parseInt(counts.count),
|
||||
invited: parseInt(counts.invitedCount),
|
||||
suspended: parseInt(counts.suspendedCount),
|
||||
};
|
||||
};
|
||||
|
||||
User.findAllInBatches = async (
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'query' implicitly has an 'any' type.
|
||||
query,
|
||||
// @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
|
||||
callback: (users: Array<User>, query: Record<string, any>) => Promise<void>
|
||||
) => {
|
||||
if (!query.offset) query.offset = 0;
|
||||
if (!query.limit) query.limit = 10;
|
||||
let results;
|
||||
|
||||
do {
|
||||
results = await User.findAll(query);
|
||||
await callback(results, query);
|
||||
query.offset += query.limit;
|
||||
} while (results.length >= query.limit);
|
||||
};
|
||||
|
||||
User.prototype.demote = async function (
|
||||
teamId: string,
|
||||
to: "member" | "viewer"
|
||||
) {
|
||||
const res = await User.findAndCountAll({
|
||||
where: {
|
||||
teamId,
|
||||
isAdmin: true,
|
||||
id: {
|
||||
[Op.ne]: this.id,
|
||||
// Returns a session token that is used to make API requests and is stored
|
||||
// in the client browser cookies to remain logged in.
|
||||
getJwtToken = (expiresAt?: Date) => {
|
||||
return JWT.sign(
|
||||
{
|
||||
id: this.id,
|
||||
expiresAt: expiresAt ? expiresAt.toISOString() : undefined,
|
||||
type: "session",
|
||||
},
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
this.jwtSecret
|
||||
);
|
||||
};
|
||||
|
||||
if (res.count >= 1) {
|
||||
if (to === "member") {
|
||||
return this.update({
|
||||
isAdmin: false,
|
||||
isViewer: false,
|
||||
});
|
||||
} else if (to === "viewer") {
|
||||
return this.update({
|
||||
isAdmin: false,
|
||||
isViewer: true,
|
||||
});
|
||||
// Returns a temporary token that is only used for transferring a session
|
||||
// between subdomains or domains. It has a short expiry and can only be used once
|
||||
getTransferToken = () => {
|
||||
return JWT.sign(
|
||||
{
|
||||
id: this.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: addMinutes(new Date(), 1).toISOString(),
|
||||
type: "transfer",
|
||||
},
|
||||
this.jwtSecret
|
||||
);
|
||||
};
|
||||
|
||||
// Returns a temporary token that is only used for logging in from an email
|
||||
// It can only be used to sign in once and has a medium length expiry
|
||||
getEmailSigninToken = () => {
|
||||
return JWT.sign(
|
||||
{
|
||||
id: this.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
type: "email-signin",
|
||||
},
|
||||
this.jwtSecret
|
||||
);
|
||||
};
|
||||
|
||||
demote = async (teamId: string, to: "member" | "viewer") => {
|
||||
const res = await (this.constructor as typeof User).findAndCountAll({
|
||||
where: {
|
||||
teamId,
|
||||
isAdmin: true,
|
||||
id: {
|
||||
[Op.ne]: this.id,
|
||||
},
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (res.count >= 1) {
|
||||
if (to === "member") {
|
||||
return this.update({
|
||||
isAdmin: false,
|
||||
isViewer: false,
|
||||
});
|
||||
} else if (to === "viewer") {
|
||||
return this.update({
|
||||
isAdmin: false,
|
||||
isViewer: true,
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} else {
|
||||
throw ValidationError("At least one admin is required");
|
||||
}
|
||||
} else {
|
||||
throw ValidationError("At least one admin is required");
|
||||
};
|
||||
|
||||
promote = () => {
|
||||
return this.update({
|
||||
isAdmin: true,
|
||||
isViewer: false,
|
||||
});
|
||||
};
|
||||
|
||||
activate = () => {
|
||||
return this.update({
|
||||
suspendedById: null,
|
||||
suspendedAt: null,
|
||||
});
|
||||
};
|
||||
|
||||
// hooks
|
||||
|
||||
@BeforeDestroy
|
||||
static removeIdentifyingInfo = async (
|
||||
model: User,
|
||||
options: { transaction: Transaction }
|
||||
) => {
|
||||
await NotificationSetting.destroy({
|
||||
where: {
|
||||
userId: model.id,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
await ApiKey.destroy({
|
||||
where: {
|
||||
userId: model.id,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
await Star.destroy({
|
||||
where: {
|
||||
userId: model.id,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
await UserAuthentication.destroy({
|
||||
where: {
|
||||
userId: model.id,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
model.email = null;
|
||||
model.name = "Unknown";
|
||||
model.avatarUrl = null;
|
||||
model.username = null;
|
||||
model.lastActiveIp = null;
|
||||
model.lastSignedInIp = null;
|
||||
|
||||
// this shouldn't be needed once this issue is resolved:
|
||||
// https://github.com/sequelize/sequelize/issues/9318
|
||||
await model.save({
|
||||
hooks: false,
|
||||
transaction: options.transaction,
|
||||
});
|
||||
};
|
||||
|
||||
@BeforeSave
|
||||
static uploadAvatar = async (model: User) => {
|
||||
const endpoint = publicS3Endpoint();
|
||||
const { avatarUrl } = model;
|
||||
|
||||
if (
|
||||
avatarUrl &&
|
||||
!avatarUrl.startsWith("/api") &&
|
||||
!avatarUrl.startsWith(endpoint) &&
|
||||
!avatarUrl.startsWith(DEFAULT_AVATAR_HOST)
|
||||
) {
|
||||
try {
|
||||
const newUrl = await uploadToS3FromUrl(
|
||||
avatarUrl,
|
||||
`avatars/${model.id}/${uuidv4()}`,
|
||||
"public-read"
|
||||
);
|
||||
if (newUrl) model.avatarUrl = newUrl;
|
||||
} catch (err) {
|
||||
Logger.error("Couldn't upload user avatar image to S3", err, {
|
||||
url: avatarUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@BeforeCreate
|
||||
static setRandomJwtSecret = (model: User) => {
|
||||
model.jwtSecret = crypto.randomBytes(64).toString("hex");
|
||||
};
|
||||
|
||||
// By default when a user signs up we subscribe them to email notifications
|
||||
// when documents they created are edited by other team members and onboarding
|
||||
@AfterCreate
|
||||
static subscribeToNotifications = async (
|
||||
model: User,
|
||||
options: { transaction: Transaction }
|
||||
) => {
|
||||
await Promise.all([
|
||||
NotificationSetting.findOrCreate({
|
||||
where: {
|
||||
userId: model.id,
|
||||
teamId: model.teamId,
|
||||
event: "documents.update",
|
||||
},
|
||||
transaction: options.transaction,
|
||||
}),
|
||||
NotificationSetting.findOrCreate({
|
||||
where: {
|
||||
userId: model.id,
|
||||
teamId: model.teamId,
|
||||
event: "emails.onboarding",
|
||||
},
|
||||
transaction: options.transaction,
|
||||
}),
|
||||
NotificationSetting.findOrCreate({
|
||||
where: {
|
||||
userId: model.id,
|
||||
teamId: model.teamId,
|
||||
event: "emails.features",
|
||||
},
|
||||
transaction: options.transaction,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
static getCounts = async function (teamId: string) {
|
||||
const countSql = `
|
||||
SELECT
|
||||
COUNT(CASE WHEN "suspendedAt" IS NOT NULL THEN 1 END) as "suspendedCount",
|
||||
COUNT(CASE WHEN "isAdmin" = true THEN 1 END) as "adminCount",
|
||||
COUNT(CASE WHEN "isViewer" = true THEN 1 END) as "viewerCount",
|
||||
COUNT(CASE WHEN "lastActiveAt" IS NULL THEN 1 END) as "invitedCount",
|
||||
COUNT(CASE WHEN "suspendedAt" IS NULL AND "lastActiveAt" IS NOT NULL THEN 1 END) as "activeCount",
|
||||
COUNT(*) as count
|
||||
FROM users
|
||||
WHERE "deletedAt" IS NULL
|
||||
AND "teamId" = :teamId
|
||||
`;
|
||||
const [results] = await this.sequelize.query(countSql, {
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
const counts: {
|
||||
activeCount: string;
|
||||
adminCount: string;
|
||||
invitedCount: string;
|
||||
suspendedCount: string;
|
||||
viewerCount: string;
|
||||
count: string;
|
||||
} = results as any;
|
||||
|
||||
return {
|
||||
active: parseInt(counts.activeCount),
|
||||
admins: parseInt(counts.adminCount),
|
||||
viewers: parseInt(counts.viewerCount),
|
||||
all: parseInt(counts.count),
|
||||
invited: parseInt(counts.invitedCount),
|
||||
suspended: parseInt(counts.suspendedCount),
|
||||
};
|
||||
};
|
||||
|
||||
static async findAllInBatches(
|
||||
query: FindOptions<User>,
|
||||
callback: (users: Array<User>, query: FindOptions<User>) => Promise<void>
|
||||
) {
|
||||
if (!query.offset) query.offset = 0;
|
||||
if (!query.limit) query.limit = 10;
|
||||
let results;
|
||||
|
||||
do {
|
||||
results = await this.findAll(query);
|
||||
await callback(results, query);
|
||||
query.offset += query.limit;
|
||||
} while (results.length >= query.limit);
|
||||
}
|
||||
};
|
||||
|
||||
User.prototype.promote = async function () {
|
||||
return this.update({
|
||||
isAdmin: true,
|
||||
isViewer: false,
|
||||
});
|
||||
};
|
||||
|
||||
User.prototype.activate = async function () {
|
||||
return this.update({
|
||||
suspendedById: null,
|
||||
suspendedAt: null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default User;
|
||||
|
||||
@@ -1,24 +1,65 @@
|
||||
import { DataTypes, sequelize, encryptedFields } from "../sequelize";
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
DataType,
|
||||
ForeignKey,
|
||||
Table,
|
||||
Unique,
|
||||
} from "sequelize-typescript";
|
||||
import AuthenticationProvider from "./AuthenticationProvider";
|
||||
import User from "./User";
|
||||
import BaseModel from "./base/BaseModel";
|
||||
import Encrypted, {
|
||||
getEncryptedColumn,
|
||||
setEncryptedColumn,
|
||||
} from "./decorators/Encrypted";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const UserAuthentication = sequelize.define("user_authentications", {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
scopes: DataTypes.ARRAY(DataTypes.STRING),
|
||||
accessToken: encryptedFields().vault("accessToken"),
|
||||
refreshToken: encryptedFields().vault("refreshToken"),
|
||||
providerId: {
|
||||
type: DataTypes.STRING,
|
||||
unique: true,
|
||||
},
|
||||
});
|
||||
@Table({ tableName: "user_authentications", modelName: "user_authentication" })
|
||||
@Fix
|
||||
class UserAuthentication extends BaseModel {
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
scopes: string[];
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
UserAuthentication.associate = (models) => {
|
||||
UserAuthentication.belongsTo(models.AuthenticationProvider);
|
||||
UserAuthentication.belongsTo(models.User);
|
||||
};
|
||||
@Column(DataType.BLOB)
|
||||
@Encrypted
|
||||
get accessToken() {
|
||||
return getEncryptedColumn(this, "accessToken");
|
||||
}
|
||||
|
||||
set accessToken(value: string) {
|
||||
setEncryptedColumn(this, "accessToken", value);
|
||||
}
|
||||
|
||||
@Column(DataType.BLOB)
|
||||
@Encrypted
|
||||
get refreshToken() {
|
||||
return getEncryptedColumn(this, "refreshToken");
|
||||
}
|
||||
|
||||
set refreshToken(value: string) {
|
||||
setEncryptedColumn(this, "refreshToken", value);
|
||||
}
|
||||
|
||||
@Column
|
||||
providerId: string;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
@BelongsTo(() => AuthenticationProvider, "providerId")
|
||||
authenticationProvider: AuthenticationProvider;
|
||||
|
||||
@ForeignKey(() => AuthenticationProvider)
|
||||
@Unique
|
||||
@Column(DataType.UUID)
|
||||
authenticationProviderId: string;
|
||||
}
|
||||
|
||||
export default UserAuthentication;
|
||||
|
||||
@@ -1,85 +1,105 @@
|
||||
import { subMilliseconds } from "date-fns";
|
||||
import { Op } from "sequelize";
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
Default,
|
||||
ForeignKey,
|
||||
Table,
|
||||
DataType,
|
||||
} from "sequelize-typescript";
|
||||
import { USER_PRESENCE_INTERVAL } from "@shared/constants";
|
||||
import { User } from "@server/models";
|
||||
import { DataTypes, Op, sequelize } from "../sequelize";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import BaseModel from "./base/BaseModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const View = sequelize.define("view", {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
lastEditingAt: {
|
||||
type: DataTypes.DATE,
|
||||
},
|
||||
count: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 1,
|
||||
},
|
||||
});
|
||||
@Table({ tableName: "views", modelName: "view" })
|
||||
@Fix
|
||||
class View extends BaseModel {
|
||||
@Column
|
||||
lastEditingAt: Date | null;
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
View.associate = (models) => {
|
||||
View.belongsTo(models.Document);
|
||||
View.belongsTo(models.User);
|
||||
};
|
||||
@Default(1)
|
||||
@Column(DataType.INTEGER)
|
||||
count: number;
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'where' implicitly has an 'any' type.
|
||||
View.increment = async (where) => {
|
||||
const [model, created] = await View.findOrCreate({
|
||||
where,
|
||||
});
|
||||
// associations
|
||||
|
||||
if (!created) {
|
||||
model.count += 1;
|
||||
model.save();
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
@BelongsTo(() => Document, "documentId")
|
||||
document: Document;
|
||||
|
||||
@ForeignKey(() => Document)
|
||||
@Column(DataType.UUID)
|
||||
documentId: string;
|
||||
|
||||
static async incrementOrCreate(where: {
|
||||
userId?: string;
|
||||
documentId?: string;
|
||||
collectionId?: string;
|
||||
}) {
|
||||
const [model, created] = await this.findOrCreate({
|
||||
where,
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
model.count += 1;
|
||||
model.save();
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
return model;
|
||||
};
|
||||
|
||||
View.findByDocument = async (documentId: string) => {
|
||||
return View.findAll({
|
||||
where: {
|
||||
documentId,
|
||||
},
|
||||
order: [["updatedAt", "DESC"]],
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
paranoid: false,
|
||||
static async findByDocument(documentId: string) {
|
||||
return this.findAll({
|
||||
where: {
|
||||
documentId,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
View.findRecentlyEditingByDocument = async (documentId: string) => {
|
||||
return View.findAll({
|
||||
where: {
|
||||
documentId,
|
||||
lastEditingAt: {
|
||||
[Op.gt]: subMilliseconds(new Date(), USER_PRESENCE_INTERVAL * 2),
|
||||
},
|
||||
},
|
||||
order: [["lastEditingAt", "DESC"]],
|
||||
});
|
||||
};
|
||||
|
||||
View.touch = async (documentId: string, userId: string, isEditing: boolean) => {
|
||||
const [view] = await View.findOrCreate({
|
||||
where: {
|
||||
userId,
|
||||
documentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (isEditing) {
|
||||
const lastEditingAt = new Date();
|
||||
view.lastEditingAt = lastEditingAt;
|
||||
await view.save();
|
||||
order: [["updatedAt", "DESC"]],
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return view;
|
||||
};
|
||||
static async findRecentlyEditingByDocument(documentId: string) {
|
||||
return this.findAll({
|
||||
where: {
|
||||
documentId,
|
||||
lastEditingAt: {
|
||||
[Op.gt]: subMilliseconds(new Date(), USER_PRESENCE_INTERVAL * 2),
|
||||
},
|
||||
},
|
||||
order: [["lastEditingAt", "DESC"]],
|
||||
});
|
||||
}
|
||||
|
||||
static async touch(documentId: string, userId: string, isEditing: boolean) {
|
||||
const [view] = await this.findOrCreate({
|
||||
where: {
|
||||
userId,
|
||||
documentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (isEditing) {
|
||||
const lastEditingAt = new Date();
|
||||
view.lastEditingAt = lastEditingAt;
|
||||
await view.save();
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
}
|
||||
|
||||
export default View;
|
||||
|
||||
26
server/models/base/BaseModel.ts
Normal file
26
server/models/base/BaseModel.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
Column,
|
||||
PrimaryKey,
|
||||
IsUUID,
|
||||
DataType,
|
||||
Model,
|
||||
Default,
|
||||
} from "sequelize-typescript";
|
||||
|
||||
class BaseModel extends Model {
|
||||
@IsUUID(4)
|
||||
@PrimaryKey
|
||||
@Default(DataType.UUIDV4)
|
||||
@Column(DataType.UUID)
|
||||
id: string;
|
||||
|
||||
@CreatedAt
|
||||
createdAt: Date;
|
||||
|
||||
@UpdatedAt
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default BaseModel;
|
||||
9
server/models/base/ParanoidModel.ts
Normal file
9
server/models/base/ParanoidModel.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { DeletedAt } from "sequelize-typescript";
|
||||
import BaseModel from "./BaseModel";
|
||||
|
||||
class ParanoidModel extends BaseModel {
|
||||
@DeletedAt
|
||||
deletedAt: Date | null;
|
||||
}
|
||||
|
||||
export default ParanoidModel;
|
||||
30
server/models/decorators/Encrypted.ts
Normal file
30
server/models/decorators/Encrypted.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import vaults from "@server/database/vaults";
|
||||
|
||||
const key = "sequelize:vault";
|
||||
|
||||
/**
|
||||
* A decorator that stores the encrypted vault for a particular database column
|
||||
* so that it can be used by getters and setters. Must be accompanied by a
|
||||
* @Column(DataType.BLOB) annotation.
|
||||
*/
|
||||
export default function Encrypted(target: any, propertyKey: string) {
|
||||
Reflect.defineMetadata(key, vaults().vault(propertyKey), target, propertyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of an encrypted column given the target and the property key.
|
||||
*/
|
||||
export function getEncryptedColumn(target: any, propertyKey: string): string {
|
||||
return Reflect.getMetadata(key, target, propertyKey).get.call(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of an encrypted column given the target and the property key.
|
||||
*/
|
||||
export function setEncryptedColumn(
|
||||
target: any,
|
||||
propertyKey: string,
|
||||
value: string
|
||||
) {
|
||||
Reflect.getMetadata(key, target, propertyKey).set.call(target, value);
|
||||
}
|
||||
50
server/models/decorators/Fix.ts
Normal file
50
server/models/decorators/Fix.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* A decorator that must be applied to every model definition to workaround
|
||||
* babel <> typescript incompatibility. See the following issue:
|
||||
* https://github.com/RobinBuschmann/sequelize-typescript/issues/612#issuecomment-491890977
|
||||
*
|
||||
* @param target model class
|
||||
*/
|
||||
|
||||
export default function Fix(target: any): void {
|
||||
return class extends target {
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
|
||||
const rawAttributes = Object.keys(new.target.rawAttributes);
|
||||
const associations = Object.keys(new.target.associations);
|
||||
|
||||
rawAttributes.forEach((propertyKey) => {
|
||||
// check if we already defined getter/setter – if so, do not override
|
||||
const desc = Object.getOwnPropertyDescriptor(
|
||||
target.prototype,
|
||||
propertyKey
|
||||
);
|
||||
if (desc) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.defineProperty(this, propertyKey, {
|
||||
get() {
|
||||
return this.getDataValue(propertyKey);
|
||||
},
|
||||
set(value) {
|
||||
this.setDataValue(propertyKey, value);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
associations.forEach((propertyKey) => {
|
||||
Object.defineProperty(this, propertyKey, {
|
||||
get() {
|
||||
return this.dataValues[propertyKey];
|
||||
},
|
||||
set(value) {
|
||||
// sets without changing the "changed" flag for associations
|
||||
this.dataValues[propertyKey] = value;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
} as any;
|
||||
}
|
||||
@@ -1,88 +1,49 @@
|
||||
import ApiKey from "./ApiKey";
|
||||
import Attachment from "./Attachment";
|
||||
import AuthenticationProvider from "./AuthenticationProvider";
|
||||
import Backlink from "./Backlink";
|
||||
import Collection from "./Collection";
|
||||
import CollectionGroup from "./CollectionGroup";
|
||||
import CollectionUser from "./CollectionUser";
|
||||
import Document from "./Document";
|
||||
import Event from "./Event";
|
||||
import FileOperation from "./FileOperation";
|
||||
import Group from "./Group";
|
||||
import GroupUser from "./GroupUser";
|
||||
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";
|
||||
import Star from "./Star";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import UserAuthentication from "./UserAuthentication";
|
||||
import View from "./View";
|
||||
export { default as ApiKey } from "./ApiKey";
|
||||
|
||||
const models = {
|
||||
ApiKey,
|
||||
Attachment,
|
||||
AuthenticationProvider,
|
||||
Backlink,
|
||||
Collection,
|
||||
CollectionGroup,
|
||||
CollectionUser,
|
||||
Document,
|
||||
Event,
|
||||
Group,
|
||||
GroupUser,
|
||||
Integration,
|
||||
IntegrationAuthentication,
|
||||
Notification,
|
||||
NotificationSetting,
|
||||
Pin,
|
||||
Revision,
|
||||
SearchQuery,
|
||||
Share,
|
||||
Star,
|
||||
Team,
|
||||
User,
|
||||
UserAuthentication,
|
||||
View,
|
||||
FileOperation,
|
||||
};
|
||||
export { default as Attachment } from "./Attachment";
|
||||
|
||||
// based on https://github.com/sequelize/express-example/blob/master/models/index.js
|
||||
Object.keys(models).forEach((modelName) => {
|
||||
if ("associate" in models[modelName]) {
|
||||
models[modelName].associate(models);
|
||||
}
|
||||
});
|
||||
export { default as AuthenticationProvider } from "./AuthenticationProvider";
|
||||
|
||||
export {
|
||||
ApiKey,
|
||||
Attachment,
|
||||
AuthenticationProvider,
|
||||
Backlink,
|
||||
Collection,
|
||||
CollectionGroup,
|
||||
CollectionUser,
|
||||
Document,
|
||||
Event,
|
||||
Group,
|
||||
GroupUser,
|
||||
Integration,
|
||||
IntegrationAuthentication,
|
||||
Notification,
|
||||
NotificationSetting,
|
||||
Pin,
|
||||
Revision,
|
||||
SearchQuery,
|
||||
Share,
|
||||
Star,
|
||||
Team,
|
||||
User,
|
||||
UserAuthentication,
|
||||
View,
|
||||
FileOperation,
|
||||
};
|
||||
export { default as Backlink } from "./Backlink";
|
||||
|
||||
export { default as Collection } from "./Collection";
|
||||
|
||||
export { default as CollectionGroup } from "./CollectionGroup";
|
||||
|
||||
export { default as CollectionUser } from "./CollectionUser";
|
||||
|
||||
export { default as Document } from "./Document";
|
||||
|
||||
export { default as Event } from "./Event";
|
||||
|
||||
export { default as FileOperation } from "./FileOperation";
|
||||
|
||||
export { default as Group } from "./Group";
|
||||
|
||||
export { default as GroupUser } from "./GroupUser";
|
||||
|
||||
export { default as Integration } from "./Integration";
|
||||
|
||||
export { default as IntegrationAuthentication } from "./IntegrationAuthentication";
|
||||
|
||||
export { default as Notification } from "./Notification";
|
||||
|
||||
export { default as NotificationSetting } from "./NotificationSetting";
|
||||
|
||||
export { default as Pin } from "./Pin";
|
||||
|
||||
export { default as Revision } from "./Revision";
|
||||
|
||||
export { default as SearchQuery } from "./SearchQuery";
|
||||
|
||||
export { default as Share } from "./Share";
|
||||
|
||||
export { default as Star } from "./Star";
|
||||
|
||||
export { default as Team } from "./Team";
|
||||
|
||||
export { default as User } from "./User";
|
||||
|
||||
export { default as UserAuthentication } from "./UserAuthentication";
|
||||
|
||||
export { default as View } from "./View";
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { ApiKey, User, Team } from "@server/models";
|
||||
import policy from "./policy";
|
||||
|
||||
const { allow } = policy;
|
||||
import { allow } from "./cancan";
|
||||
|
||||
allow(User, "createApiKey", Team, (user, team) => {
|
||||
if (!team || user.isViewer || user.teamId !== team.id) return false;
|
||||
@@ -9,6 +7,7 @@ allow(User, "createApiKey", Team, (user, team) => {
|
||||
});
|
||||
|
||||
allow(User, ["read", "update", "delete"], ApiKey, (user, apiKey) => {
|
||||
if (!apiKey) return false;
|
||||
if (user.isViewer) return false;
|
||||
return user && user.id === apiKey.userId;
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Attachment, User, Team } from "@server/models";
|
||||
import policy from "./policy";
|
||||
|
||||
const { allow } = policy;
|
||||
import { allow } from "./cancan";
|
||||
|
||||
allow(User, "createAttachment", Team, (user, team) => {
|
||||
if (!team || user.isViewer || user.teamId !== team.id) return false;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { AuthenticationProvider, User, Team } from "@server/models";
|
||||
import { AdminRequiredError } from "../errors";
|
||||
import policy from "./policy";
|
||||
|
||||
const { allow } = policy;
|
||||
import { allow } from "./cancan";
|
||||
|
||||
allow(User, "createAuthenticationProvider", Team, (actor, team) => {
|
||||
if (!team || actor.teamId !== team.id) return false;
|
||||
@@ -17,7 +15,7 @@ allow(
|
||||
AuthenticationProvider,
|
||||
|
||||
(actor, authenticationProvider) =>
|
||||
actor && actor.teamId === authenticationProvider.teamId
|
||||
actor && actor.teamId === authenticationProvider?.teamId
|
||||
);
|
||||
|
||||
allow(
|
||||
@@ -26,7 +24,7 @@ allow(
|
||||
AuthenticationProvider,
|
||||
|
||||
(actor, authenticationProvider) => {
|
||||
if (actor.teamId !== authenticationProvider.teamId) return false;
|
||||
if (actor.teamId !== authenticationProvider?.teamId) return false;
|
||||
if (actor.isAdmin) return true;
|
||||
|
||||
throw AdminRequiredError();
|
||||
|
||||
13
server/policies/cancan.ts
Normal file
13
server/policies/cancan.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import CanCan from "cancan";
|
||||
|
||||
const cancan = new CanCan();
|
||||
|
||||
export const _can = cancan.can;
|
||||
|
||||
export const _authorize = cancan.authorize;
|
||||
|
||||
export const _cannot = cancan.cannot;
|
||||
|
||||
export const _abilities = cancan.abilities;
|
||||
|
||||
export const allow = cancan.allow;
|
||||
@@ -4,6 +4,7 @@ import { flushdb } from "@server/test/support";
|
||||
import { serialize } from "./index";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("read_write permission", () => {
|
||||
it("should allow read write permissions for team member", async () => {
|
||||
const team = await buildTeam();
|
||||
@@ -25,7 +26,7 @@ describe("read_write permission", () => {
|
||||
const user = await buildUser({
|
||||
teamId: team.id,
|
||||
});
|
||||
let collection = await buildCollection({
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
permission: "read_write",
|
||||
});
|
||||
@@ -36,15 +37,16 @@ describe("read_write permission", () => {
|
||||
permission: "read",
|
||||
});
|
||||
// reload to get membership
|
||||
collection = await Collection.scope({
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collection.id);
|
||||
const abilities = serialize(user, collection);
|
||||
const abilities = serialize(user, reloaded);
|
||||
expect(abilities.read).toEqual(true);
|
||||
expect(abilities.update).toEqual(true);
|
||||
expect(abilities.share).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("read permission", () => {
|
||||
it("should allow read permissions for team member", async () => {
|
||||
const team = await buildTeam();
|
||||
@@ -66,7 +68,7 @@ describe("read permission", () => {
|
||||
const user = await buildUser({
|
||||
teamId: team.id,
|
||||
});
|
||||
let collection = await buildCollection({
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
permission: "read",
|
||||
});
|
||||
@@ -77,15 +79,16 @@ describe("read permission", () => {
|
||||
permission: "read_write",
|
||||
});
|
||||
// reload to get membership
|
||||
collection = await Collection.scope({
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collection.id);
|
||||
const abilities = serialize(user, collection);
|
||||
const abilities = serialize(user, reloaded);
|
||||
expect(abilities.read).toEqual(true);
|
||||
expect(abilities.update).toEqual(true);
|
||||
expect(abilities.share).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no permission", () => {
|
||||
it("should allow no permissions for team member", async () => {
|
||||
const team = await buildTeam();
|
||||
@@ -107,7 +110,7 @@ describe("no permission", () => {
|
||||
const user = await buildUser({
|
||||
teamId: team.id,
|
||||
});
|
||||
let collection = await buildCollection({
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
permission: null,
|
||||
});
|
||||
@@ -118,10 +121,10 @@ describe("no permission", () => {
|
||||
permission: "read_write",
|
||||
});
|
||||
// reload to get membership
|
||||
collection = await Collection.scope({
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collection.id);
|
||||
const abilities = serialize(user, collection);
|
||||
const abilities = serialize(user, reloaded);
|
||||
expect(abilities.read).toEqual(true);
|
||||
expect(abilities.update).toEqual(true);
|
||||
expect(abilities.share).toEqual(true);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import invariant from "invariant";
|
||||
import { concat, some } from "lodash";
|
||||
import { some } from "lodash";
|
||||
import { Collection, User, Team } from "@server/models";
|
||||
import { AdminRequiredError } from "../errors";
|
||||
import policy from "./policy";
|
||||
|
||||
const { allow } = policy;
|
||||
import { allow } from "./cancan";
|
||||
|
||||
allow(User, "createCollection", Team, (user, team) => {
|
||||
if (!team || user.isViewer || user.teamId !== team.id) return false;
|
||||
@@ -34,10 +32,10 @@ allow(User, "read", Collection, (user, collection) => {
|
||||
collection.memberships,
|
||||
"membership should be preloaded, did you forget withMembership scope?"
|
||||
);
|
||||
const allMemberships = concat(
|
||||
collection.memberships,
|
||||
collection.collectionGroupMemberships
|
||||
);
|
||||
const allMemberships = [
|
||||
...collection.memberships,
|
||||
...collection.collectionGroupMemberships,
|
||||
];
|
||||
return some(allMemberships, (m) =>
|
||||
["read", "read_write", "maintainer"].includes(m.permission)
|
||||
);
|
||||
@@ -56,10 +54,10 @@ allow(User, "share", Collection, (user, collection) => {
|
||||
collection.memberships,
|
||||
"membership should be preloaded, did you forget withMembership scope?"
|
||||
);
|
||||
const allMemberships = concat(
|
||||
collection.memberships,
|
||||
collection.collectionGroupMemberships
|
||||
);
|
||||
const allMemberships = [
|
||||
...collection.memberships,
|
||||
...collection.collectionGroupMemberships,
|
||||
];
|
||||
return some(allMemberships, (m) =>
|
||||
["read_write", "maintainer"].includes(m.permission)
|
||||
);
|
||||
@@ -77,10 +75,10 @@ allow(User, ["publish", "update"], Collection, (user, collection) => {
|
||||
collection.memberships,
|
||||
"membership should be preloaded, did you forget withMembership scope?"
|
||||
);
|
||||
const allMemberships = concat(
|
||||
collection.memberships,
|
||||
collection.collectionGroupMemberships
|
||||
);
|
||||
const allMemberships = [
|
||||
...collection.memberships,
|
||||
...collection.collectionGroupMemberships,
|
||||
];
|
||||
return some(allMemberships, (m) =>
|
||||
["read_write", "maintainer"].includes(m.permission)
|
||||
);
|
||||
@@ -98,10 +96,10 @@ allow(User, "delete", Collection, (user, collection) => {
|
||||
collection.memberships,
|
||||
"membership should be preloaded, did you forget withMembership scope?"
|
||||
);
|
||||
const allMemberships = concat(
|
||||
collection.memberships,
|
||||
collection.collectionGroupMemberships
|
||||
);
|
||||
const allMemberships = [
|
||||
...collection.memberships,
|
||||
...collection.collectionGroupMemberships,
|
||||
];
|
||||
return some(allMemberships, (m) =>
|
||||
["read_write", "maintainer"].includes(m.permission)
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { flushdb } from "@server/test/support";
|
||||
import { serialize } from "./index";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("read_write collection", () => {
|
||||
it("should allow read write permissions for team member", async () => {
|
||||
const team = await buildTeam();
|
||||
@@ -33,6 +34,7 @@ describe("read_write collection", () => {
|
||||
expect(abilities.move).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("read collection", () => {
|
||||
it("should allow read only permissions permissions for team member", async () => {
|
||||
const team = await buildTeam();
|
||||
@@ -58,6 +60,7 @@ describe("read collection", () => {
|
||||
expect(abilities.move).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("private collection", () => {
|
||||
it("should allow no permissions for team member", async () => {
|
||||
const team = await buildTeam();
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import invariant from "invariant";
|
||||
import { Document, Revision, User, Team } from "@server/models";
|
||||
import policy from "./policy";
|
||||
|
||||
const { allow, cannot } = policy;
|
||||
import { NavigationNode } from "~/types";
|
||||
import { allow, _cannot as cannot } from "./cancan";
|
||||
|
||||
allow(User, "createDocument", Team, (user, team) => {
|
||||
if (!team || user.isViewer || user.teamId !== team.id) return false;
|
||||
@@ -10,6 +9,8 @@ allow(User, "createDocument", Team, (user, team) => {
|
||||
});
|
||||
|
||||
allow(User, ["read", "download"], Document, (user, document) => {
|
||||
if (!document) return false;
|
||||
|
||||
// existence of collection option is not required here to account for share tokens
|
||||
if (document.collection && cannot(user, "read", document.collection)) {
|
||||
return false;
|
||||
@@ -19,6 +20,7 @@ allow(User, ["read", "download"], Document, (user, document) => {
|
||||
});
|
||||
|
||||
allow(User, ["star", "unstar"], Document, (user, document) => {
|
||||
if (!document) return false;
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
if (document.template) return false;
|
||||
@@ -31,8 +33,13 @@ allow(User, ["star", "unstar"], Document, (user, document) => {
|
||||
});
|
||||
|
||||
allow(User, "share", Document, (user, document) => {
|
||||
if (!document) return false;
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
invariant(
|
||||
document.collection,
|
||||
"collection is missing, did you forget to include in the query scope?"
|
||||
);
|
||||
|
||||
if (cannot(user, "share", document.collection)) {
|
||||
return false;
|
||||
@@ -42,6 +49,7 @@ allow(User, "share", Document, (user, document) => {
|
||||
});
|
||||
|
||||
allow(User, "update", Document, (user, document) => {
|
||||
if (!document) return false;
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
@@ -53,6 +61,7 @@ allow(User, "update", Document, (user, document) => {
|
||||
});
|
||||
|
||||
allow(User, "createChildDocument", Document, (user, document) => {
|
||||
if (!document) return false;
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
if (document.template) return false;
|
||||
@@ -66,6 +75,7 @@ allow(User, "createChildDocument", Document, (user, document) => {
|
||||
});
|
||||
|
||||
allow(User, "move", Document, (user, document) => {
|
||||
if (!document) return false;
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
if (!document.publishedAt) return false;
|
||||
@@ -78,6 +88,7 @@ allow(User, "move", Document, (user, document) => {
|
||||
});
|
||||
|
||||
allow(User, ["pin", "unpin"], Document, (user, document) => {
|
||||
if (!document) return false;
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
if (document.template) return false;
|
||||
@@ -91,6 +102,7 @@ allow(User, ["pin", "unpin"], Document, (user, document) => {
|
||||
});
|
||||
|
||||
allow(User, ["pinToHome"], Document, (user, document) => {
|
||||
if (!document) return false;
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
if (document.template) return false;
|
||||
@@ -100,8 +112,9 @@ allow(User, ["pinToHome"], Document, (user, document) => {
|
||||
});
|
||||
|
||||
allow(User, "delete", Document, (user, document) => {
|
||||
if (user.isViewer) return false;
|
||||
if (!document) return false;
|
||||
if (document.deletedAt) return false;
|
||||
if (user.isViewer) return false;
|
||||
|
||||
// allow deleting document without a collection
|
||||
if (document.collection && cannot(user, "update", document.collection)) {
|
||||
@@ -121,8 +134,9 @@ allow(User, "delete", Document, (user, document) => {
|
||||
});
|
||||
|
||||
allow(User, "permanentDelete", Document, (user, document) => {
|
||||
if (user.isViewer) return false;
|
||||
if (!document) return false;
|
||||
if (!document.deletedAt) return false;
|
||||
if (user.isViewer) return false;
|
||||
|
||||
// allow deleting document without a collection
|
||||
if (document.collection && cannot(user, "update", document.collection)) {
|
||||
@@ -133,8 +147,9 @@ allow(User, "permanentDelete", Document, (user, document) => {
|
||||
});
|
||||
|
||||
allow(User, "restore", Document, (user, document) => {
|
||||
if (user.isViewer) return false;
|
||||
if (!document) return false;
|
||||
if (!document.deletedAt) return false;
|
||||
if (user.isViewer) return false;
|
||||
|
||||
if (document.collection && cannot(user, "update", document.collection)) {
|
||||
return false;
|
||||
@@ -144,6 +159,7 @@ allow(User, "restore", Document, (user, document) => {
|
||||
});
|
||||
|
||||
allow(User, "archive", Document, (user, document) => {
|
||||
if (!document) return false;
|
||||
if (!document.publishedAt) return false;
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
@@ -156,6 +172,7 @@ allow(User, "archive", Document, (user, document) => {
|
||||
});
|
||||
|
||||
allow(User, "unarchive", Document, (user, document) => {
|
||||
if (!document) return false;
|
||||
invariant(
|
||||
document.collection,
|
||||
"collection is missing, did you forget to include in the query scope?"
|
||||
@@ -170,10 +187,11 @@ allow(
|
||||
Document,
|
||||
"restore",
|
||||
Revision,
|
||||
(document, revision) => document.id === revision.documentId
|
||||
(document, revision) => document.id === revision?.documentId
|
||||
);
|
||||
|
||||
allow(User, "unpublish", Document, (user, document) => {
|
||||
if (!document) return false;
|
||||
invariant(
|
||||
document.collection,
|
||||
"collection is missing, did you forget to include in the query scope?"
|
||||
@@ -183,16 +201,14 @@ allow(User, "unpublish", Document, (user, document) => {
|
||||
if (cannot(user, "update", document.collection)) return false;
|
||||
const documentID = document.id;
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'documents' implicitly has an 'any' type... Remove this comment to see the full error message
|
||||
const hasChild = (documents) =>
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'doc' implicitly has an 'any' type.
|
||||
const hasChild = (documents: NavigationNode[]): boolean =>
|
||||
documents.some((doc) => {
|
||||
if (doc.id === documentID) return doc.children.length > 0;
|
||||
return hasChild(doc.children);
|
||||
});
|
||||
|
||||
return (
|
||||
!hasChild(document.collection.documentStructure) &&
|
||||
!hasChild(document.collection.documentStructure || []) &&
|
||||
user.teamId === document.teamId
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Group, User, Team } from "@server/models";
|
||||
import { AdminRequiredError } from "../errors";
|
||||
import policy from "./policy";
|
||||
|
||||
const { allow } = policy;
|
||||
import { allow } from "./cancan";
|
||||
|
||||
allow(User, "createGroup", Team, (actor, team) => {
|
||||
if (!team || actor.isViewer || actor.teamId !== team.id) return false;
|
||||
|
||||
@@ -3,12 +3,14 @@ import { flushdb } from "@server/test/support";
|
||||
import { serialize } from "./index";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
it("should serialize policy", async () => {
|
||||
const user = await buildUser();
|
||||
const response = serialize(user, user);
|
||||
expect(response.update).toEqual(true);
|
||||
expect(response.delete).toEqual(true);
|
||||
});
|
||||
|
||||
it("should serialize domain policies on Team", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Document,
|
||||
Group,
|
||||
} from "@server/models";
|
||||
import policy from "./policy";
|
||||
import { _abilities, _can, _cannot, _authorize } from "./cancan";
|
||||
import "./apiKey";
|
||||
import "./attachment";
|
||||
import "./authenticationProvider";
|
||||
@@ -21,19 +21,26 @@ import "./user";
|
||||
import "./team";
|
||||
import "./group";
|
||||
|
||||
const { can, abilities } = policy;
|
||||
type Policy = Record<string, boolean>;
|
||||
|
||||
// this should not be needed but is a workaround for this TypeScript issue:
|
||||
// https://github.com/microsoft/TypeScript/issues/36931
|
||||
export const authorize: typeof _authorize = _authorize;
|
||||
|
||||
export const can = _can;
|
||||
|
||||
export const cannot = _cannot;
|
||||
|
||||
export const abilities = _abilities;
|
||||
|
||||
/*
|
||||
* Given a user and a model – output an object which describes the actions the
|
||||
* user may take against the model. This serialized policy is used for testing
|
||||
* and sent in API responses to allow clients to adjust which UI is displayed.
|
||||
*/
|
||||
export function serialize(
|
||||
// @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
|
||||
model: User,
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Attachment' refers to a value, but is being used ... Remove this comment to see the full error message
|
||||
target: Attachment | Team | Collection | Document | Group
|
||||
target: Attachment | Team | Collection | Document | User | Group | null
|
||||
): Policy {
|
||||
const output = {};
|
||||
abilities.forEach((ability) => {
|
||||
@@ -51,5 +58,3 @@ export function serialize(
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
export default policy;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Integration, User, Team } from "@server/models";
|
||||
import { AdminRequiredError } from "../errors";
|
||||
import policy from "./policy";
|
||||
|
||||
const { allow } = policy;
|
||||
import { allow } from "./cancan";
|
||||
|
||||
allow(User, "createIntegration", Team, (actor, team) => {
|
||||
if (!team || actor.isViewer || actor.teamId !== team.id) return false;
|
||||
@@ -15,7 +13,7 @@ allow(
|
||||
User,
|
||||
"read",
|
||||
Integration,
|
||||
(user, integration) => user.teamId === integration.teamId
|
||||
(user, integration) => user.teamId === integration?.teamId
|
||||
);
|
||||
|
||||
allow(User, ["update", "delete"], Integration, (user, integration) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user