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

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