Adding a ton of stuff

This commit is contained in:
2026-07-17 04:39:18 -07:00
parent 8d9fe8e75d
commit de28a67417
63 changed files with 2027 additions and 613 deletions
@@ -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
+4
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
// Public (unauthenticated) controllers
export { BlogPostsController } from "./blog-posts-controller"
+1
View File
@@ -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")
}
+8 -8
View File
@@ -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 },
})
}
+69
View File
@@ -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
View File
@@ -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,
}
+108
View File
@@ -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
View File
@@ -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"],
})
}
}
+57
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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"
+2
View File
@@ -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
+1
View File
@@ -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)
}