chore: Typescript database models (#2886)

closes #2798
This commit is contained in:
Tom Moor
2022-01-06 18:24:28 -08:00
committed by GitHub
parent d3cbf250e6
commit b20a341f0c
207 changed files with 5624 additions and 5315 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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

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

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

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

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

View File

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