@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user