generated from alphane/template
Adding a ton of stuff
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { isNil } from "lodash"
|
||||
|
||||
import logger from "@/utils/logger"
|
||||
import { BlogPost } from "@/models"
|
||||
import { BlogPostsPolicy } from "@/policies"
|
||||
import { CreateService, DestroyService, UpdateService } from "@/services/blog-posts"
|
||||
import { IndexSerializer, ShowSerializer } from "@/serializers/blog-posts"
|
||||
import BaseController from "@/controllers/base-controller"
|
||||
|
||||
export class BlogPostsController extends BaseController<BlogPost> {
|
||||
async index() {
|
||||
try {
|
||||
const where = this.buildWhere()
|
||||
const scopes = this.buildFilterScopes()
|
||||
const scopedBlogPosts = BlogPostsPolicy.applyScope(scopes, this.currentUser)
|
||||
|
||||
const totalCount = await scopedBlogPosts.count({ where })
|
||||
const blogPosts = await scopedBlogPosts.findAll({
|
||||
where,
|
||||
limit: this.pagination.limit,
|
||||
offset: this.pagination.offset,
|
||||
order: this.buildOrder(),
|
||||
})
|
||||
const serializedBlogPosts = IndexSerializer.perform(blogPosts)
|
||||
return this.response.json({ blogPosts: serializedBlogPosts, totalCount })
|
||||
} catch (error) {
|
||||
logger.error("Error fetching blog posts" + error)
|
||||
return this.response.status(400).json({ message: `Error fetching blog posts: ${error}` })
|
||||
}
|
||||
}
|
||||
|
||||
async show() {
|
||||
try {
|
||||
const blogPost = await this.loadBlogPost()
|
||||
if (isNil(blogPost)) {
|
||||
return this.response.status(404).json({ message: "Blog post not found" })
|
||||
}
|
||||
|
||||
const policy = this.buildPolicy(blogPost)
|
||||
if (!policy.show()) {
|
||||
return this.response
|
||||
.status(403)
|
||||
.json({ message: "You are not authorized to view this blog post" })
|
||||
}
|
||||
|
||||
const serializedBlogPost = ShowSerializer.perform(blogPost)
|
||||
return this.response.json({ blogPost: serializedBlogPost, policy })
|
||||
} catch (error) {
|
||||
logger.error("Error fetching blog post" + error)
|
||||
return this.response.status(400).json({ message: `Error fetching blog post: ${error}` })
|
||||
}
|
||||
}
|
||||
|
||||
async create() {
|
||||
try {
|
||||
const policy = this.buildPolicy()
|
||||
if (!policy.create()) {
|
||||
return this.response
|
||||
.status(403)
|
||||
.json({ message: "You are not authorized to create blog posts" })
|
||||
}
|
||||
|
||||
const permittedAttributes = policy.permitAttributesForCreate(this.request.body)
|
||||
const blogPost = await CreateService.perform({
|
||||
...permittedAttributes,
|
||||
creatorId: this.currentUser.id,
|
||||
})
|
||||
const serializedBlogPost = ShowSerializer.perform(blogPost)
|
||||
return this.response.status(201).json({ blogPost: serializedBlogPost })
|
||||
} catch (error) {
|
||||
logger.error("Error creating blog post" + error)
|
||||
return this.response.status(422).json({ message: `Error creating blog post: ${error}` })
|
||||
}
|
||||
}
|
||||
|
||||
async update() {
|
||||
try {
|
||||
const blogPost = await this.loadBlogPost()
|
||||
if (isNil(blogPost)) {
|
||||
return this.response.status(404).json({ message: "Blog post not found" })
|
||||
}
|
||||
|
||||
const policy = this.buildPolicy(blogPost)
|
||||
if (!policy.update()) {
|
||||
return this.response
|
||||
.status(403)
|
||||
.json({ message: "You are not authorized to update this blog post" })
|
||||
}
|
||||
|
||||
const permittedAttributes = policy.permitAttributes(this.request.body)
|
||||
const updatedBlogPost = await UpdateService.perform(blogPost, permittedAttributes)
|
||||
const serializedBlogPost = ShowSerializer.perform(updatedBlogPost)
|
||||
return this.response.json({ blogPost: serializedBlogPost })
|
||||
} catch (error) {
|
||||
logger.error("Error updating blog post" + error)
|
||||
return this.response.status(422).json({ message: `Error updating blog post: ${error}` })
|
||||
}
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
try {
|
||||
const blogPost = await this.loadBlogPost()
|
||||
if (isNil(blogPost)) {
|
||||
return this.response.status(404).json({ message: "Blog post not found" })
|
||||
}
|
||||
|
||||
const policy = this.buildPolicy(blogPost)
|
||||
if (!policy.destroy()) {
|
||||
return this.response
|
||||
.status(403)
|
||||
.json({ message: "You are not authorized to delete this blog post" })
|
||||
}
|
||||
|
||||
await DestroyService.perform(blogPost)
|
||||
return this.response.status(204).send()
|
||||
} catch (error) {
|
||||
logger.error("Error deleting blog post" + error)
|
||||
return this.response.status(422).json({ message: `Error deleting blog post: ${error}` })
|
||||
}
|
||||
}
|
||||
|
||||
private async loadBlogPost() {
|
||||
return BlogPost.findBySlugOrPk(this.params.blogPostIdOrSlug)
|
||||
}
|
||||
|
||||
private buildPolicy(blogPost: BlogPost = BlogPost.build()) {
|
||||
return new BlogPostsPolicy(this.currentUser, blogPost)
|
||||
}
|
||||
}
|
||||
|
||||
export default BlogPostsController
|
||||
@@ -1,5 +1,9 @@
|
||||
// Controllers
|
||||
export { BlogPostsController } from "./blog-posts-controller"
|
||||
export { CurrentUserController } from "./current-user-controller"
|
||||
export { FlashcardDecksController } from "./flashcard-decks-controller"
|
||||
export { FlashcardsController } from "./flashcards-controller"
|
||||
export { UsersController } from "./users-controller"
|
||||
|
||||
export * as Public from "./public"
|
||||
export * as Users from "./users"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Model } from "@sequelize/core"
|
||||
|
||||
import BaseController from "@/controllers/base-controller"
|
||||
|
||||
// Base controller for public, unauthenticated routes: these are mounted
|
||||
// before jwtMiddleware/authorizationMiddleware in router.ts, so no
|
||||
// currentUser is ever available.
|
||||
export class PublicBaseController<TModel extends Model = never> extends BaseController<TModel> {
|
||||
get currentUser(): never {
|
||||
throw new Error("currentUser is not available on public controllers")
|
||||
}
|
||||
}
|
||||
|
||||
export default PublicBaseController
|
||||
@@ -0,0 +1,47 @@
|
||||
import { isNil } from "lodash"
|
||||
|
||||
import logger from "@/utils/logger"
|
||||
import { BlogPost } from "@/models"
|
||||
import { IndexSerializer, ShowSerializer } from "@/serializers/blog-posts"
|
||||
import PublicBaseController from "@/controllers/public/base-controller"
|
||||
|
||||
export class BlogPostsController extends PublicBaseController<BlogPost> {
|
||||
async index() {
|
||||
try {
|
||||
const where = this.buildWhere()
|
||||
const totalCount = await BlogPost.count({ where })
|
||||
const blogPosts = await BlogPost.findAll({
|
||||
where,
|
||||
limit: this.pagination.limit,
|
||||
offset: this.pagination.offset,
|
||||
order: this.buildOrder(),
|
||||
})
|
||||
const serializedBlogPosts = IndexSerializer.perform(blogPosts)
|
||||
return this.response.json({ blogPosts: serializedBlogPosts, totalCount })
|
||||
} catch (error) {
|
||||
logger.error("Error fetching blog posts" + error)
|
||||
return this.response.status(400).json({ message: `Error fetching blog posts: ${error}` })
|
||||
}
|
||||
}
|
||||
|
||||
async show() {
|
||||
try {
|
||||
const blogPost = await this.loadBlogPost()
|
||||
if (isNil(blogPost)) {
|
||||
return this.response.status(404).json({ message: "Blog post not found" })
|
||||
}
|
||||
|
||||
const serializedBlogPost = ShowSerializer.perform(blogPost)
|
||||
return this.response.json({ blogPost: serializedBlogPost })
|
||||
} catch (error) {
|
||||
logger.error("Error fetching blog post" + error)
|
||||
return this.response.status(400).json({ message: `Error fetching blog post: ${error}` })
|
||||
}
|
||||
}
|
||||
|
||||
private async loadBlogPost() {
|
||||
return BlogPost.findBySlugOrPk(this.params.blogPostIdOrSlug)
|
||||
}
|
||||
}
|
||||
|
||||
export default BlogPostsController
|
||||
@@ -0,0 +1,2 @@
|
||||
// Public (unauthenticated) controllers
|
||||
export { BlogPostsController } from "./blog-posts-controller"
|
||||
@@ -0,0 +1 @@
|
||||
export { PreferencesController } from "./preferences-controller"
|
||||
@@ -0,0 +1,49 @@
|
||||
import { isNil } from "lodash"
|
||||
|
||||
import logger from "@/utils/logger"
|
||||
|
||||
import { User } from "@/models"
|
||||
import { UpsertService } from "@/services/users/preferences"
|
||||
import { UsersPolicy } from "@/policies/users-policy"
|
||||
import BaseController from "@/controllers/base-controller"
|
||||
|
||||
export class PreferencesController extends BaseController {
|
||||
async update() {
|
||||
try {
|
||||
const user = await this.loadUser()
|
||||
if (isNil(user)) {
|
||||
return this.response.status(404).json({
|
||||
message: "User not found",
|
||||
})
|
||||
}
|
||||
|
||||
const policy = this.buildPolicy(user)
|
||||
if (!policy.update()) {
|
||||
return this.response.status(403).json({
|
||||
message: "You are not authorized to update this user",
|
||||
})
|
||||
}
|
||||
|
||||
const key = this.request.params.key as string
|
||||
const { value } = this.request.body
|
||||
await UpsertService.perform(user, key, value)
|
||||
|
||||
return this.response.json({ message: "Successfully upserted user preference" })
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
return this.response.status(422).json({
|
||||
message: `Error updating user: ${error}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async loadUser(): Promise<User | null> {
|
||||
return User.findByPk(this.params.UserId)
|
||||
}
|
||||
|
||||
private buildPolicy(User: User) {
|
||||
return new UsersPolicy(this.currentUser, User)
|
||||
}
|
||||
}
|
||||
|
||||
export default PreferencesController
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Knex } from "knex"
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.createTable("blog_posts", function (table) {
|
||||
table.increments("id").notNullable().primary()
|
||||
table.integer("creator_id").notNullable()
|
||||
table.string("title", 255).notNullable()
|
||||
table.string("slug", 255).notNullable()
|
||||
table.text("content").notNullable()
|
||||
table.string("tags", 1024).notNullable().defaultTo("")
|
||||
table.specificType("published_at", "TIMESTAMP WITH TIME ZONE")
|
||||
|
||||
table
|
||||
.specificType("created_at", "TIMESTAMP WITH TIME ZONE")
|
||||
.notNullable()
|
||||
.defaultTo(knex.raw("CURRENT_TIMESTAMP(0)"))
|
||||
table
|
||||
.specificType("updated_at", "TIMESTAMP WITH TIME ZONE")
|
||||
.notNullable()
|
||||
.defaultTo(knex.raw("CURRENT_TIMESTAMP(0)"))
|
||||
table.specificType("deleted_at", "TIMESTAMP WITH TIME ZONE")
|
||||
|
||||
table.foreign("creator_id").references("users.id")
|
||||
table.unique(["slug"], {
|
||||
indexName: "blog_posts_slug_unique",
|
||||
predicate: knex.whereNull("deleted_at"),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTable("blog_posts")
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Knex } from "knex"
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.createTable("user_preferences", (table) => {
|
||||
table.increments("id").primary()
|
||||
table.integer("user_id").notNullable()
|
||||
table.string("key", 255).notNullable()
|
||||
table.jsonb("value").nullable()
|
||||
table
|
||||
.specificType("created_at", "TIMESTAMP WITH TIME ZONE")
|
||||
.notNullable()
|
||||
.defaultTo(knex.raw("CURRENT_TIMESTAMP(0)"))
|
||||
table
|
||||
.specificType("updated_at", "TIMESTAMP WITH TIME ZONE")
|
||||
.notNullable()
|
||||
.defaultTo(knex.raw("CURRENT_TIMESTAMP(0)"))
|
||||
table.specificType("deleted_at", "TIMESTAMP WITH TIME ZONE")
|
||||
|
||||
table.foreign("user_id").references("users.id")
|
||||
|
||||
table.unique(["user_id", "key"], {
|
||||
indexName: "unique_user_preference_when_deleted_at_is_null",
|
||||
predicate: knex.whereNull("deleted_at"),
|
||||
})
|
||||
|
||||
table.index(["user_id"], "preferences_user_id")
|
||||
})
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTable("user_preferences")
|
||||
}
|
||||
@@ -45,25 +45,25 @@ export abstract class BaseModel<
|
||||
// identifier: unknown,
|
||||
// options?: FindByPkOptions<M>,
|
||||
// ): Promise<M | null>;
|
||||
public static async findByIdentifierOrPk<M extends BaseModel>(
|
||||
public static async findBySlugOrPk<M extends BaseModel>(
|
||||
this: ModelStatic<M>,
|
||||
identifierOrPk: string | number,
|
||||
slugOrPk: unknown,
|
||||
options?: Omit<FindOptions<Attributes<M>>, "where">
|
||||
): Promise<M | null> {
|
||||
if (typeof identifierOrPk === "number" || !isNaN(Number(identifierOrPk))) {
|
||||
const primaryKey = identifierOrPk
|
||||
if (typeof slugOrPk === "number" || !isNaN(Number(slugOrPk))) {
|
||||
const primaryKey = slugOrPk
|
||||
return this.findByPk(primaryKey, options)
|
||||
}
|
||||
|
||||
const identifier = identifierOrPk
|
||||
if (!("identifier" in this.getAttributes())) {
|
||||
throw new Error(`${this.name} does not have a 'identifier' attribute.`)
|
||||
const slug = slugOrPk
|
||||
if (!("slug" in this.getAttributes())) {
|
||||
throw new Error(`${this.name} does not have a 'slug' attribute.`)
|
||||
}
|
||||
|
||||
return this.findOne({
|
||||
...options,
|
||||
// @ts-expect-error - We know that the model has a slug attribute, and are ignoring the TS error
|
||||
where: { identifier },
|
||||
where: { slug },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { type CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, sql } from "@sequelize/core"
|
||||
import { Attribute, AutoIncrement, Default, Index, NotNull, PrimaryKey } from "@sequelize/core/decorators-legacy"
|
||||
import { isNil } from "lodash"
|
||||
|
||||
import BaseModel from "@/models/base-model"
|
||||
|
||||
export class BlogPost extends BaseModel<InferAttributes<BlogPost>, InferCreationAttributes<BlogPost>> {
|
||||
@Attribute(DataTypes.INTEGER)
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
declare id: CreationOptional<number>
|
||||
|
||||
@Attribute(DataTypes.INTEGER)
|
||||
@NotNull
|
||||
declare creatorId: number
|
||||
|
||||
@Attribute(DataTypes.STRING(255))
|
||||
@NotNull
|
||||
declare title: string
|
||||
|
||||
@Attribute(DataTypes.STRING(255))
|
||||
@NotNull
|
||||
@Index({ unique: true })
|
||||
declare slug: string
|
||||
|
||||
@Attribute(DataTypes.TEXT)
|
||||
@NotNull
|
||||
declare content: string
|
||||
|
||||
@Attribute({
|
||||
type: DataTypes.STRING(1024),
|
||||
get() {
|
||||
const tags = this.getDataValue("tags")
|
||||
if (isNil(tags) || tags === "") {
|
||||
return []
|
||||
}
|
||||
return tags.split(",")
|
||||
},
|
||||
set(value: string[]) {
|
||||
this.setDataValue("tags", value.join(","))
|
||||
},
|
||||
})
|
||||
@NotNull
|
||||
@Default("")
|
||||
declare tags: CreationOptional<string[]>
|
||||
|
||||
@Attribute(DataTypes.DATE(0))
|
||||
declare publishedAt: Date | null
|
||||
|
||||
@Attribute(DataTypes.DATE(0))
|
||||
@NotNull
|
||||
@Default(sql.literal("CURRENT_TIMESTAMP"))
|
||||
declare createdAt: CreationOptional<Date>
|
||||
|
||||
@Attribute(DataTypes.DATE(0))
|
||||
@NotNull
|
||||
@Default(sql.literal("CURRENT_TIMESTAMP"))
|
||||
declare updatedAt: CreationOptional<Date>
|
||||
|
||||
@Attribute(DataTypes.DATE(0))
|
||||
declare deletedAt: Date | null
|
||||
|
||||
// Scopes
|
||||
static establishScopes(): void {
|
||||
this.addSearchScope(["title", "content"])
|
||||
}
|
||||
}
|
||||
|
||||
export default BlogPost
|
||||
+6
-11
@@ -1,27 +1,22 @@
|
||||
import db from "@/db/db-client"
|
||||
|
||||
// Models
|
||||
import BlogPost from "@/models/blog-post"
|
||||
import Flashcard from "@/models/flashcard"
|
||||
import FlashcardDeck from "@/models/flashcard-deck"
|
||||
import User, { UserRoles } from "@/models/user"
|
||||
import UserPreference from "@/models/user-preference"
|
||||
|
||||
db.addModels([
|
||||
Flashcard,
|
||||
FlashcardDeck,
|
||||
User,
|
||||
])
|
||||
db.addModels([BlogPost, Flashcard, FlashcardDeck, User, UserPreference])
|
||||
|
||||
// Lazy load scopes
|
||||
BlogPost.establishScopes()
|
||||
Flashcard.establishScopes()
|
||||
FlashcardDeck.establishScopes()
|
||||
User.establishScopes()
|
||||
UserPreference.establishScopes()
|
||||
|
||||
export {
|
||||
Flashcard,
|
||||
FlashcardDeck,
|
||||
User,
|
||||
UserRoles,
|
||||
}
|
||||
export { BlogPost, Flashcard, FlashcardDeck, User, UserRoles, UserPreference }
|
||||
|
||||
// Special db instance will all models loaded
|
||||
export default db
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PreferenceKeys, PreferenceValueMap } from "@/models/user-preference"
|
||||
import { isFiniteNumber } from "@/utils/validators"
|
||||
|
||||
export const preferenceValueValidators: {
|
||||
[K in PreferenceKeys]: (value: unknown) => value is PreferenceValueMap[K]
|
||||
} = {
|
||||
[PreferenceKeys.FLASHCARDS_DAY_STREAK]: isFiniteNumber,
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
type CreationOptional,
|
||||
type ForeignKey,
|
||||
DataTypes,
|
||||
InferAttributes,
|
||||
InferCreationAttributes,
|
||||
type NonAttribute,
|
||||
sql,
|
||||
} from "@sequelize/core"
|
||||
import {
|
||||
Attribute,
|
||||
AutoIncrement,
|
||||
BelongsTo,
|
||||
Default,
|
||||
NotNull,
|
||||
PrimaryKey,
|
||||
Table,
|
||||
ValidateAttribute,
|
||||
} from "@sequelize/core/decorators-legacy"
|
||||
|
||||
import BaseModel from "@/models/base-model"
|
||||
import { User } from "@/models/user"
|
||||
|
||||
/**
|
||||
* Keep in sync with web/src/api/users/preferences-api.ts and
|
||||
* api/src/models/user-preference-value-validators.ts
|
||||
*/
|
||||
export enum PreferenceKeys {
|
||||
FLASHCARDS_DAY_STREAK = "flashcards.day_streak",
|
||||
}
|
||||
|
||||
export type PreferenceValueMap = {
|
||||
[PreferenceKeys.FLASHCARDS_DAY_STREAK]: number
|
||||
}
|
||||
|
||||
@Table({ tableName: "user_preferences", timestamps: true, paranoid: true })
|
||||
export class UserPreference extends BaseModel<
|
||||
InferAttributes<UserPreference>,
|
||||
InferCreationAttributes<UserPreference>
|
||||
> {
|
||||
static readonly Keys = PreferenceKeys
|
||||
|
||||
@Attribute(DataTypes.INTEGER)
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
declare id: CreationOptional<number>
|
||||
|
||||
@Attribute(DataTypes.INTEGER)
|
||||
@NotNull
|
||||
declare userId: ForeignKey<User["id"]>
|
||||
|
||||
@Attribute(DataTypes.STRING(255))
|
||||
@NotNull
|
||||
@ValidateAttribute({
|
||||
isIn: {
|
||||
args: [Object.values(PreferenceKeys)],
|
||||
msg: `Key must be one of ${Object.values(PreferenceKeys).join(", ")}`,
|
||||
},
|
||||
})
|
||||
declare key: PreferenceKeys
|
||||
|
||||
@Attribute(DataTypes.JSONB)
|
||||
declare value: unknown | null
|
||||
|
||||
@Attribute(DataTypes.DATE(0))
|
||||
@NotNull
|
||||
@Default(sql.literal("CURRENT_TIMESTAMP"))
|
||||
declare createdAt: CreationOptional<Date>
|
||||
|
||||
@Attribute(DataTypes.DATE(0))
|
||||
@NotNull
|
||||
@Default(sql.literal("CURRENT_TIMESTAMP"))
|
||||
declare updatedAt: CreationOptional<Date>
|
||||
|
||||
@Attribute(DataTypes.DATE(0))
|
||||
declare deletedAt: Date | null
|
||||
|
||||
// Associations
|
||||
@BelongsTo(() => User, {
|
||||
foreignKey: { name: "userId", allowNull: false },
|
||||
inverse: {
|
||||
as: "userPreferences",
|
||||
type: "hasMany",
|
||||
},
|
||||
})
|
||||
declare user?: NonAttribute<User>
|
||||
|
||||
// Scopes
|
||||
static establishScopes(): void {}
|
||||
|
||||
static async setValue<T extends PreferenceKeys>(
|
||||
userId: number,
|
||||
key: T,
|
||||
value: PreferenceValueMap[T]
|
||||
) {
|
||||
const existing = await UserPreference.findOne({
|
||||
where: { userId, key },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return existing.update({ value })
|
||||
}
|
||||
|
||||
return UserPreference.create({ userId, key, value })
|
||||
}
|
||||
}
|
||||
|
||||
export default UserPreference
|
||||
+13
-1
@@ -10,6 +10,7 @@ import {
|
||||
Attribute,
|
||||
AutoIncrement,
|
||||
Default,
|
||||
HasMany,
|
||||
Index,
|
||||
NotNull,
|
||||
PrimaryKey,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
import { isArray, isNil } from "lodash"
|
||||
|
||||
import BaseModel from "@/models/base-model"
|
||||
import UserPreference from "@/models/user-preference"
|
||||
|
||||
/** Keep in sync with web/src/api/users-api.ts */
|
||||
export enum UserRoles {
|
||||
@@ -98,12 +100,22 @@ export class User extends BaseModel<InferAttributes<User>, InferCreationAttribut
|
||||
}
|
||||
|
||||
// Associations
|
||||
@HasMany(() => UserPreference, {
|
||||
foreignKey: {
|
||||
name: "userId",
|
||||
allowNull: false,
|
||||
},
|
||||
inverse: "user",
|
||||
})
|
||||
declare userPreferences?: NonAttribute<UserPreference[]>
|
||||
|
||||
// Scopes
|
||||
static establishScopes(): void {
|
||||
this.addSearchScope(["firstName", "lastName", "displayName", "email"])
|
||||
|
||||
this.addScope("asCurrentUser", {})
|
||||
this.addScope("asCurrentUser", {
|
||||
include: ["userPreferences"],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Attributes, FindOptions } from "@sequelize/core"
|
||||
|
||||
import { BlogPost, User } from "@/models"
|
||||
import { ALL_RECORDS_SCOPE, PolicyFactory } from "@/policies/base-policy"
|
||||
import { Path } from "@/utils/deep-pick"
|
||||
|
||||
export class BlogPostsPolicy extends PolicyFactory(BlogPost) {
|
||||
show(): boolean {
|
||||
if (this.user.isSystemAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
create(): boolean {
|
||||
if (this.user.isSystemAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
update(): boolean {
|
||||
if (this.user.isSystemAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
destroy(): boolean {
|
||||
if (this.user.isSystemAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
permittedAttributes(): Path[] {
|
||||
return ["title", "slug", "content", "tags", "publishedAt"] as (keyof Attributes<BlogPost>)[]
|
||||
}
|
||||
|
||||
permittedAttributesForCreate(): Path[] {
|
||||
return [...this.permittedAttributes()]
|
||||
}
|
||||
|
||||
permittedAttributesForUpdate(): Path[] {
|
||||
return [...this.permittedAttributes()]
|
||||
}
|
||||
|
||||
static policyScope(_user: User): FindOptions<Attributes<BlogPost>> {
|
||||
return ALL_RECORDS_SCOPE
|
||||
}
|
||||
}
|
||||
|
||||
export default BlogPostsPolicy
|
||||
@@ -1,5 +1,6 @@
|
||||
// Policy Bundles
|
||||
export { type BaseScopeOptions } from "./base-policy"
|
||||
export { BlogPostsPolicy } from "./blog-posts-policy"
|
||||
export { FlashcardDecksPolicy } from "./flashcard-decks-policy"
|
||||
export { FlashcardsPolicy } from "./flashcards-policy"
|
||||
export { UsersPolicy } from "./users-policy"
|
||||
|
||||
+20
-5
@@ -16,7 +16,15 @@ import { logger } from "@/utils/logger"
|
||||
|
||||
import { jwtMiddleware, authorizationMiddleware } from "@/middlewares"
|
||||
|
||||
import { CurrentUserController, FlashcardDecksController, FlashcardsController, UsersController } from "@/controllers"
|
||||
import {
|
||||
BlogPostsController,
|
||||
CurrentUserController,
|
||||
FlashcardDecksController,
|
||||
FlashcardsController,
|
||||
Public,
|
||||
Users,
|
||||
UsersController,
|
||||
} from "@/controllers"
|
||||
|
||||
export const router = Router()
|
||||
|
||||
@@ -29,6 +37,8 @@ router.route("/_status").get((_req: Request, res: Response) => {
|
||||
})
|
||||
|
||||
// external (public) routes - no authentication required
|
||||
router.route("/api/public/blog-posts").get(Public.BlogPostsController.index)
|
||||
router.route("/api/public/blog-posts/:blogPostIdOrSlug").get(Public.BlogPostsController.show)
|
||||
|
||||
// api routes
|
||||
router.use("/api", jwtMiddleware, authorizationMiddleware)
|
||||
@@ -41,6 +51,7 @@ router
|
||||
.get(UsersController.show)
|
||||
.patch(UsersController.update)
|
||||
.delete(UsersController.destroy)
|
||||
router.route("/api/users/:userId/preferences/:key").patch(Users.PreferencesController.update)
|
||||
|
||||
router
|
||||
.route("/api/flashcard-decks")
|
||||
@@ -52,16 +63,20 @@ router
|
||||
.patch(FlashcardDecksController.update)
|
||||
.delete(FlashcardDecksController.destroy)
|
||||
|
||||
router
|
||||
.route("/api/flashcards")
|
||||
.get(FlashcardsController.index)
|
||||
.post(FlashcardsController.create)
|
||||
router.route("/api/flashcards").get(FlashcardsController.index).post(FlashcardsController.create)
|
||||
router
|
||||
.route("/api/flashcards/:flashcardId")
|
||||
.get(FlashcardsController.show)
|
||||
.patch(FlashcardsController.update)
|
||||
.delete(FlashcardsController.destroy)
|
||||
|
||||
router.route("/api/blog-posts").get(BlogPostsController.index).post(BlogPostsController.create)
|
||||
router
|
||||
.route("/api/blog-posts/:blogPostIdOrSlug")
|
||||
.get(BlogPostsController.show)
|
||||
.patch(BlogPostsController.update)
|
||||
.delete(BlogPostsController.destroy)
|
||||
|
||||
// if no other routes match, return a 404
|
||||
router.use("/api", (req: Request, res: Response) => {
|
||||
return res.status(404).json({ message: "Not Found", url: req.path })
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { pick } from "lodash"
|
||||
|
||||
import { BlogPost } from "@/models"
|
||||
import BaseSerializer from "@/serializers/base-serializer"
|
||||
|
||||
export type BlogPostIndexView = Pick<
|
||||
BlogPost,
|
||||
"id" | "creatorId" | "title" | "slug" | "tags" | "publishedAt" | "createdAt" | "updatedAt"
|
||||
>
|
||||
|
||||
export class IndexSerializer extends BaseSerializer<BlogPost> {
|
||||
perform(): BlogPostIndexView {
|
||||
return pick(this.record, [
|
||||
"id",
|
||||
"creatorId",
|
||||
"title",
|
||||
"slug",
|
||||
"tags",
|
||||
"publishedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
export default IndexSerializer
|
||||
@@ -0,0 +1,2 @@
|
||||
export { IndexSerializer } from "./index-serializer"
|
||||
export { ShowSerializer } from "./show-serializer"
|
||||
@@ -0,0 +1,35 @@
|
||||
import { pick } from "lodash"
|
||||
|
||||
import { BlogPost } from "@/models"
|
||||
import BaseSerializer from "@/serializers/base-serializer"
|
||||
|
||||
export type BlogPostShowView = Pick<
|
||||
BlogPost,
|
||||
| "id"
|
||||
| "creatorId"
|
||||
| "title"
|
||||
| "slug"
|
||||
| "content"
|
||||
| "tags"
|
||||
| "publishedAt"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
>
|
||||
|
||||
export class ShowSerializer extends BaseSerializer<BlogPost> {
|
||||
perform(): BlogPostShowView {
|
||||
return pick(this.record, [
|
||||
"id",
|
||||
"creatorId",
|
||||
"title",
|
||||
"slug",
|
||||
"content",
|
||||
"tags",
|
||||
"publishedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
export default ShowSerializer
|
||||
@@ -1,15 +1,27 @@
|
||||
import { pick } from "lodash"
|
||||
import { isUndefined, pick } from "lodash"
|
||||
|
||||
import { User } from "@/models"
|
||||
import { User, UserPreference } from "@/models"
|
||||
import BaseSerializer from "@/serializers/base-serializer"
|
||||
import {
|
||||
ShowSerializer as UserPreferenceShowSerializer,
|
||||
type UserPreferenceShowView,
|
||||
} from "@/serializers/user-preferences/show-serializer"
|
||||
|
||||
export type UserShowView = Pick<
|
||||
User,
|
||||
"id" | "email" | "firstName" | "lastName" | "displayName" | "roles" | "createdAt" | "updatedAt"
|
||||
>
|
||||
> & {
|
||||
preferences?: UserPreferenceShowView[]
|
||||
}
|
||||
|
||||
export class ShowSerializer extends BaseSerializer<User> {
|
||||
perform(): UserShowView {
|
||||
const { userPreferences } = this.record
|
||||
|
||||
const serializedUserPreferences = isUndefined(userPreferences)
|
||||
? undefined
|
||||
: this.serializeUserPreferences(userPreferences)
|
||||
|
||||
return {
|
||||
...pick(this.record, [
|
||||
"id",
|
||||
@@ -21,8 +33,15 @@ export class ShowSerializer extends BaseSerializer<User> {
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
]),
|
||||
preferences: serializedUserPreferences,
|
||||
}
|
||||
}
|
||||
|
||||
private serializeUserPreferences(userPreferences: UserPreference[]) {
|
||||
return userPreferences.map((userPreference) =>
|
||||
UserPreferenceShowSerializer.perform(userPreference)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ShowSerializer
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Bundled exports
|
||||
export * as BlogPosts from "./blog-posts"
|
||||
export * as FlashcardDecks from "./flashcard-decks"
|
||||
export * as Flashcards from "./flashcards"
|
||||
export * as Users from "./users"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ShowSerializer } from "./show-serializer"
|
||||
@@ -0,0 +1,16 @@
|
||||
import { pick } from "lodash"
|
||||
|
||||
import { UserPreference } from "@/models"
|
||||
import BaseSerializer from "@/serializers/base-serializer"
|
||||
|
||||
export type UserPreferenceShowView = Pick<UserPreference, "key" | "value">
|
||||
|
||||
export class ShowSerializer extends BaseSerializer<UserPreference> {
|
||||
perform(): UserPreferenceShowView {
|
||||
return {
|
||||
...pick(this.record, ["key", "value"]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ShowSerializer
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CreationAttributes } from "@sequelize/core"
|
||||
import { isNil } from "lodash"
|
||||
|
||||
import { BlogPost } from "@/models"
|
||||
import BaseService from "@/services/base-service"
|
||||
|
||||
export type BlogPostCreationAttributes = Partial<CreationAttributes<BlogPost>>
|
||||
|
||||
export class CreateService extends BaseService {
|
||||
constructor(private attributes: BlogPostCreationAttributes) {
|
||||
super()
|
||||
}
|
||||
|
||||
async perform(): Promise<BlogPost> {
|
||||
const { creatorId, title, slug, content, ...optionalAttributes } = this.attributes
|
||||
|
||||
if (isNil(creatorId)) {
|
||||
throw new Error("Creator is required")
|
||||
}
|
||||
|
||||
if (isNil(title)) {
|
||||
throw new Error("Title is required")
|
||||
}
|
||||
|
||||
if (isNil(slug)) {
|
||||
throw new Error("Slug is required")
|
||||
}
|
||||
|
||||
if (isNil(content)) {
|
||||
throw new Error("Content is required")
|
||||
}
|
||||
|
||||
return BlogPost.create({
|
||||
...optionalAttributes,
|
||||
creatorId,
|
||||
title,
|
||||
slug,
|
||||
content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default CreateService
|
||||
@@ -0,0 +1,14 @@
|
||||
import { BlogPost } from "@/models"
|
||||
import BaseService from "@/services/base-service"
|
||||
|
||||
export class DestroyService extends BaseService {
|
||||
constructor(private blogPost: BlogPost) {
|
||||
super()
|
||||
}
|
||||
|
||||
async perform(): Promise<void> {
|
||||
return this.blogPost.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
export default DestroyService
|
||||
@@ -0,0 +1,3 @@
|
||||
export { CreateService } from "./create-service"
|
||||
export { UpdateService } from "./update-service"
|
||||
export { DestroyService } from "./destroy-service"
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Attributes } from "@sequelize/core"
|
||||
|
||||
import { BlogPost } from "@/models"
|
||||
import BaseService from "@/services/base-service"
|
||||
|
||||
export type BlogPostUpdateAttributes = Partial<Attributes<BlogPost>>
|
||||
|
||||
export class UpdateService extends BaseService {
|
||||
constructor(
|
||||
private blogPost: BlogPost,
|
||||
private attributes: BlogPostUpdateAttributes
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async perform(): Promise<BlogPost> {
|
||||
return this.blogPost.update(this.attributes)
|
||||
}
|
||||
}
|
||||
|
||||
export default UpdateService
|
||||
@@ -1,3 +1,4 @@
|
||||
export * as BlogPosts from "./blog-posts"
|
||||
export * as FlashcardDecks from "./flashcard-decks"
|
||||
export * as Flashcards from "./flashcards"
|
||||
export * as Users from "./users"
|
||||
|
||||
@@ -5,3 +5,5 @@ export { DestroyService } from "./destroy-service"
|
||||
// Special Services
|
||||
export { EnsureFromAuth0TokenService } from "./ensure-from-auth0-token-service"
|
||||
export { FindFromAuth0TokenService } from "./find-from-auth0-token-service"
|
||||
|
||||
export * as Preferences from "./preferences"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { UpsertService } from "./upsert-service"
|
||||
@@ -0,0 +1,32 @@
|
||||
import { User } from "@/models"
|
||||
import UserPreference, { PreferenceKeys } from "@/models/user-preference"
|
||||
import { preferenceValueValidators } from "@/models/user-preference-value-validators"
|
||||
import BaseService from "@/services/base-service"
|
||||
|
||||
export class UpsertService extends BaseService {
|
||||
constructor(
|
||||
private user: User,
|
||||
private key: string,
|
||||
private value: unknown | null
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async perform() {
|
||||
const isPreferenceKey = Object.values(PreferenceKeys).includes(this.key as PreferenceKeys)
|
||||
if (!isPreferenceKey) {
|
||||
throw new Error("Provided key is not a valid PreferenceKey")
|
||||
}
|
||||
|
||||
const key = this.key as PreferenceKeys
|
||||
|
||||
const validator = preferenceValueValidators[key]
|
||||
if (!validator(this.value)) {
|
||||
throw new Error("Invalid value for preference key")
|
||||
}
|
||||
|
||||
return UserPreference.setValue(this.user.id, key, this.value)
|
||||
}
|
||||
}
|
||||
|
||||
export default UpsertService
|
||||
@@ -0,0 +1 @@
|
||||
export { isFiniteNumber } from "./is-finite-number"
|
||||
@@ -0,0 +1,5 @@
|
||||
import { isNumber } from "lodash"
|
||||
|
||||
export function isFiniteNumber(value: unknown): value is number {
|
||||
return isNumber(value) && isFinite(value)
|
||||
}
|
||||
Reference in New Issue
Block a user