diff --git a/api/src/controllers/blog-posts-controller.ts b/api/src/controllers/blog-posts-controller.ts new file mode 100644 index 0000000..324bb57 --- /dev/null +++ b/api/src/controllers/blog-posts-controller.ts @@ -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 { + 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 diff --git a/api/src/controllers/index.ts b/api/src/controllers/index.ts index ae73d6e..45d37a7 100644 --- a/api/src/controllers/index.ts +++ b/api/src/controllers/index.ts @@ -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" diff --git a/api/src/controllers/public/base-controller.ts b/api/src/controllers/public/base-controller.ts new file mode 100644 index 0000000..9ac1b28 --- /dev/null +++ b/api/src/controllers/public/base-controller.ts @@ -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 extends BaseController { + get currentUser(): never { + throw new Error("currentUser is not available on public controllers") + } +} + +export default PublicBaseController diff --git a/api/src/controllers/public/blog-posts-controller.ts b/api/src/controllers/public/blog-posts-controller.ts new file mode 100644 index 0000000..639de54 --- /dev/null +++ b/api/src/controllers/public/blog-posts-controller.ts @@ -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 { + 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 diff --git a/api/src/controllers/public/index.ts b/api/src/controllers/public/index.ts new file mode 100644 index 0000000..c89eff3 --- /dev/null +++ b/api/src/controllers/public/index.ts @@ -0,0 +1,2 @@ +// Public (unauthenticated) controllers +export { BlogPostsController } from "./blog-posts-controller" diff --git a/api/src/controllers/users/index.ts b/api/src/controllers/users/index.ts new file mode 100644 index 0000000..eba9e75 --- /dev/null +++ b/api/src/controllers/users/index.ts @@ -0,0 +1 @@ +export { PreferencesController } from "./preferences-controller" diff --git a/api/src/controllers/users/preferences-controller.ts b/api/src/controllers/users/preferences-controller.ts new file mode 100644 index 0000000..dfb3e47 --- /dev/null +++ b/api/src/controllers/users/preferences-controller.ts @@ -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 { + return User.findByPk(this.params.UserId) + } + + private buildPolicy(User: User) { + return new UsersPolicy(this.currentUser, User) + } +} + +export default PreferencesController diff --git a/api/src/db/migrations/20260715084016_create-blog-posts-table.ts b/api/src/db/migrations/20260715084016_create-blog-posts-table.ts new file mode 100644 index 0000000..d201eae --- /dev/null +++ b/api/src/db/migrations/20260715084016_create-blog-posts-table.ts @@ -0,0 +1,33 @@ +import type { Knex } from "knex" + +export async function up(knex: Knex): Promise { + 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 { + await knex.schema.dropTable("blog_posts") +} diff --git a/api/src/db/migrations/20260716080139_create-user-preferences-table.ts b/api/src/db/migrations/20260716080139_create-user-preferences-table.ts new file mode 100644 index 0000000..1ba5855 --- /dev/null +++ b/api/src/db/migrations/20260716080139_create-user-preferences-table.ts @@ -0,0 +1,32 @@ +import type { Knex } from "knex" + +export async function up(knex: Knex): Promise { + 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 { + await knex.schema.dropTable("user_preferences") +} diff --git a/api/src/models/base-model.ts b/api/src/models/base-model.ts index 301ace9..3b609cb 100644 --- a/api/src/models/base-model.ts +++ b/api/src/models/base-model.ts @@ -45,25 +45,25 @@ export abstract class BaseModel< // identifier: unknown, // options?: FindByPkOptions, // ): Promise; - public static async findByIdentifierOrPk( + public static async findBySlugOrPk( this: ModelStatic, - identifierOrPk: string | number, + slugOrPk: unknown, options?: Omit>, "where"> ): Promise { - 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 }, }) } diff --git a/api/src/models/blog-post.ts b/api/src/models/blog-post.ts new file mode 100644 index 0000000..5493e40 --- /dev/null +++ b/api/src/models/blog-post.ts @@ -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, InferCreationAttributes> { + @Attribute(DataTypes.INTEGER) + @PrimaryKey + @AutoIncrement + declare id: CreationOptional + + @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 + + @Attribute(DataTypes.DATE(0)) + declare publishedAt: Date | null + + @Attribute(DataTypes.DATE(0)) + @NotNull + @Default(sql.literal("CURRENT_TIMESTAMP")) + declare createdAt: CreationOptional + + @Attribute(DataTypes.DATE(0)) + @NotNull + @Default(sql.literal("CURRENT_TIMESTAMP")) + declare updatedAt: CreationOptional + + @Attribute(DataTypes.DATE(0)) + declare deletedAt: Date | null + + // Scopes + static establishScopes(): void { + this.addSearchScope(["title", "content"]) + } +} + +export default BlogPost diff --git a/api/src/models/index.ts b/api/src/models/index.ts index 75a6508..9c7c1f5 100644 --- a/api/src/models/index.ts +++ b/api/src/models/index.ts @@ -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 diff --git a/api/src/models/user-preference-value-validators.ts b/api/src/models/user-preference-value-validators.ts new file mode 100644 index 0000000..ee3a2e7 --- /dev/null +++ b/api/src/models/user-preference-value-validators.ts @@ -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, +} diff --git a/api/src/models/user-preference.ts b/api/src/models/user-preference.ts new file mode 100644 index 0000000..d54408f --- /dev/null +++ b/api/src/models/user-preference.ts @@ -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, + InferCreationAttributes +> { + static readonly Keys = PreferenceKeys + + @Attribute(DataTypes.INTEGER) + @PrimaryKey + @AutoIncrement + declare id: CreationOptional + + @Attribute(DataTypes.INTEGER) + @NotNull + declare userId: ForeignKey + + @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 + + @Attribute(DataTypes.DATE(0)) + @NotNull + @Default(sql.literal("CURRENT_TIMESTAMP")) + declare updatedAt: CreationOptional + + @Attribute(DataTypes.DATE(0)) + declare deletedAt: Date | null + + // Associations + @BelongsTo(() => User, { + foreignKey: { name: "userId", allowNull: false }, + inverse: { + as: "userPreferences", + type: "hasMany", + }, + }) + declare user?: NonAttribute + + // Scopes + static establishScopes(): void {} + + static async setValue( + 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 diff --git a/api/src/models/user.ts b/api/src/models/user.ts index 7f58369..ec46154 100644 --- a/api/src/models/user.ts +++ b/api/src/models/user.ts @@ -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, InferCreationAttribut } // Associations + @HasMany(() => UserPreference, { + foreignKey: { + name: "userId", + allowNull: false, + }, + inverse: "user", + }) + declare userPreferences?: NonAttribute // Scopes static establishScopes(): void { this.addSearchScope(["firstName", "lastName", "displayName", "email"]) - this.addScope("asCurrentUser", {}) + this.addScope("asCurrentUser", { + include: ["userPreferences"], + }) } } diff --git a/api/src/policies/blog-posts-policy.ts b/api/src/policies/blog-posts-policy.ts new file mode 100644 index 0000000..55f8cd1 --- /dev/null +++ b/api/src/policies/blog-posts-policy.ts @@ -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)[] + } + + permittedAttributesForCreate(): Path[] { + return [...this.permittedAttributes()] + } + + permittedAttributesForUpdate(): Path[] { + return [...this.permittedAttributes()] + } + + static policyScope(_user: User): FindOptions> { + return ALL_RECORDS_SCOPE + } +} + +export default BlogPostsPolicy diff --git a/api/src/policies/index.ts b/api/src/policies/index.ts index 04a4724..e9e9a5c 100644 --- a/api/src/policies/index.ts +++ b/api/src/policies/index.ts @@ -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" diff --git a/api/src/router.ts b/api/src/router.ts index 402a1fc..19381e5 100644 --- a/api/src/router.ts +++ b/api/src/router.ts @@ -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 }) diff --git a/api/src/serializers/blog-posts/index-serializer.ts b/api/src/serializers/blog-posts/index-serializer.ts new file mode 100644 index 0000000..b02955b --- /dev/null +++ b/api/src/serializers/blog-posts/index-serializer.ts @@ -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 { + perform(): BlogPostIndexView { + return pick(this.record, [ + "id", + "creatorId", + "title", + "slug", + "tags", + "publishedAt", + "createdAt", + "updatedAt", + ]) + } +} + +export default IndexSerializer diff --git a/api/src/serializers/blog-posts/index.ts b/api/src/serializers/blog-posts/index.ts new file mode 100644 index 0000000..8f3b8f6 --- /dev/null +++ b/api/src/serializers/blog-posts/index.ts @@ -0,0 +1,2 @@ +export { IndexSerializer } from "./index-serializer" +export { ShowSerializer } from "./show-serializer" diff --git a/api/src/serializers/blog-posts/show-serializer.ts b/api/src/serializers/blog-posts/show-serializer.ts new file mode 100644 index 0000000..db0fd33 --- /dev/null +++ b/api/src/serializers/blog-posts/show-serializer.ts @@ -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 { + perform(): BlogPostShowView { + return pick(this.record, [ + "id", + "creatorId", + "title", + "slug", + "content", + "tags", + "publishedAt", + "createdAt", + "updatedAt", + ]) + } +} + +export default ShowSerializer diff --git a/api/src/serializers/current-user/show-serializer.ts b/api/src/serializers/current-user/show-serializer.ts index 5ab9ac3..e4ced1c 100644 --- a/api/src/serializers/current-user/show-serializer.ts +++ b/api/src/serializers/current-user/show-serializer.ts @@ -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 { 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 { "createdAt", "updatedAt", ]), + preferences: serializedUserPreferences, } } + + private serializeUserPreferences(userPreferences: UserPreference[]) { + return userPreferences.map((userPreference) => + UserPreferenceShowSerializer.perform(userPreference) + ) + } } export default ShowSerializer diff --git a/api/src/serializers/index.ts b/api/src/serializers/index.ts index df02417..5f03584 100644 --- a/api/src/serializers/index.ts +++ b/api/src/serializers/index.ts @@ -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" diff --git a/api/src/serializers/user-preferences/index.ts b/api/src/serializers/user-preferences/index.ts new file mode 100644 index 0000000..d3f3c0b --- /dev/null +++ b/api/src/serializers/user-preferences/index.ts @@ -0,0 +1 @@ +export { ShowSerializer } from "./show-serializer" diff --git a/api/src/serializers/user-preferences/show-serializer.ts b/api/src/serializers/user-preferences/show-serializer.ts new file mode 100644 index 0000000..57b0601 --- /dev/null +++ b/api/src/serializers/user-preferences/show-serializer.ts @@ -0,0 +1,16 @@ +import { pick } from "lodash" + +import { UserPreference } from "@/models" +import BaseSerializer from "@/serializers/base-serializer" + +export type UserPreferenceShowView = Pick + +export class ShowSerializer extends BaseSerializer { + perform(): UserPreferenceShowView { + return { + ...pick(this.record, ["key", "value"]), + } + } +} + +export default ShowSerializer diff --git a/api/src/services/blog-posts/create-service.ts b/api/src/services/blog-posts/create-service.ts new file mode 100644 index 0000000..47267f1 --- /dev/null +++ b/api/src/services/blog-posts/create-service.ts @@ -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> + +export class CreateService extends BaseService { + constructor(private attributes: BlogPostCreationAttributes) { + super() + } + + async perform(): Promise { + 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 diff --git a/api/src/services/blog-posts/destroy-service.ts b/api/src/services/blog-posts/destroy-service.ts new file mode 100644 index 0000000..abc245a --- /dev/null +++ b/api/src/services/blog-posts/destroy-service.ts @@ -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 { + return this.blogPost.destroy() + } +} + +export default DestroyService diff --git a/api/src/services/blog-posts/index.ts b/api/src/services/blog-posts/index.ts new file mode 100644 index 0000000..6aaf3e5 --- /dev/null +++ b/api/src/services/blog-posts/index.ts @@ -0,0 +1,3 @@ +export { CreateService } from "./create-service" +export { UpdateService } from "./update-service" +export { DestroyService } from "./destroy-service" diff --git a/api/src/services/blog-posts/update-service.ts b/api/src/services/blog-posts/update-service.ts new file mode 100644 index 0000000..33703ad --- /dev/null +++ b/api/src/services/blog-posts/update-service.ts @@ -0,0 +1,21 @@ +import { Attributes } from "@sequelize/core" + +import { BlogPost } from "@/models" +import BaseService from "@/services/base-service" + +export type BlogPostUpdateAttributes = Partial> + +export class UpdateService extends BaseService { + constructor( + private blogPost: BlogPost, + private attributes: BlogPostUpdateAttributes + ) { + super() + } + + async perform(): Promise { + return this.blogPost.update(this.attributes) + } +} + +export default UpdateService diff --git a/api/src/services/index.ts b/api/src/services/index.ts index 506cffc..8327e6b 100644 --- a/api/src/services/index.ts +++ b/api/src/services/index.ts @@ -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" diff --git a/api/src/services/users/index.ts b/api/src/services/users/index.ts index 2793bd6..9d9703f 100644 --- a/api/src/services/users/index.ts +++ b/api/src/services/users/index.ts @@ -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" diff --git a/api/src/services/users/preferences/index.ts b/api/src/services/users/preferences/index.ts new file mode 100644 index 0000000..b11a0f4 --- /dev/null +++ b/api/src/services/users/preferences/index.ts @@ -0,0 +1 @@ +export { UpsertService } from "./upsert-service" diff --git a/api/src/services/users/preferences/upsert-service.ts b/api/src/services/users/preferences/upsert-service.ts new file mode 100644 index 0000000..4fb9959 --- /dev/null +++ b/api/src/services/users/preferences/upsert-service.ts @@ -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 diff --git a/api/src/utils/validators/index.ts b/api/src/utils/validators/index.ts new file mode 100644 index 0000000..286a23d --- /dev/null +++ b/api/src/utils/validators/index.ts @@ -0,0 +1 @@ +export { isFiniteNumber } from "./is-finite-number" diff --git a/api/src/utils/validators/is-finite-number.ts b/api/src/utils/validators/is-finite-number.ts new file mode 100644 index 0000000..40bfbd0 --- /dev/null +++ b/api/src/utils/validators/is-finite-number.ts @@ -0,0 +1,5 @@ +import { isNumber } from "lodash" + +export function isFiniteNumber(value: unknown): value is number { + return isNumber(value) && isFinite(value) +} diff --git a/web/index.html b/web/index.html index 76a9c09..1b474d4 100644 --- a/web/index.html +++ b/web/index.html @@ -11,7 +11,7 @@ crossorigin /> diff --git a/web/src/api/blog-posts-api.ts b/web/src/api/blog-posts-api.ts new file mode 100644 index 0000000..7d81c3d --- /dev/null +++ b/web/src/api/blog-posts-api.ts @@ -0,0 +1,67 @@ +import http from "@/api/http-client" +import { type FiltersOptions, type ModelOrder, type WhereOptions } from "@/api/base-api" + +export type BlogPost = { + id: number + creatorId: number + title: string + slug: string + content: string + tags: string[] + publishedAt: string | null + createdAt: string + updatedAt: string +} + +export type BlogPostIndexView = Omit + +export type BlogPostWhereOptions = WhereOptions + +export type BlogPostFiltersOptions = FiltersOptions<{ + search: string | string[] +}> + +export type BlogPostQueryOptions = { + where?: BlogPostWhereOptions + filters?: BlogPostFiltersOptions + order?: ModelOrder[] + page?: number + perPage?: number +} + +export const blogPostsApi = { + async list(params: BlogPostQueryOptions = {}): Promise<{ + blogPosts: BlogPostIndexView[] + totalCount: number + }> { + const { data } = await http.get("/api/blog-posts", { params }) + return data + }, + async get(blogPostIdOrSlug: number | string): Promise<{ + blogPost: BlogPost + }> { + const { data } = await http.get(`/api/blog-posts/${blogPostIdOrSlug}`) + return data + }, + async create(attributes: Partial): Promise<{ + blogPost: BlogPost + }> { + const { data } = await http.post("/api/blog-posts", attributes) + return data + }, + async update( + blogPostIdOrSlug: number | string, + attributes: Partial + ): Promise<{ + blogPost: BlogPost + }> { + const { data } = await http.patch(`/api/blog-posts/${blogPostIdOrSlug}`, attributes) + return data + }, + async delete(blogPostIdOrSlug: number | string): Promise { + const { data } = await http.delete(`/api/blog-posts/${blogPostIdOrSlug}`) + return data + }, +} + +export default blogPostsApi diff --git a/web/src/api/current-user-api.ts b/web/src/api/current-user-api.ts index ce67e1b..24846f2 100644 --- a/web/src/api/current-user-api.ts +++ b/web/src/api/current-user-api.ts @@ -2,13 +2,16 @@ import http from "@/api/http-client" import { type Policy } from "@/api/base-api" import { UserRoles, type User } from "@/api/users-api" +import { PreferenceAsShow } from "@/api/users/preferences-api" export { UserRoles } export type UserAsShow = Pick< User, "id" | "email" | "firstName" | "lastName" | "displayName" | "roles" | "createdAt" | "updatedAt" -> +> & { + preferences?: PreferenceAsShow[] +} export const currentUserApi = { async get(): Promise<{ diff --git a/web/src/api/http-client.ts b/web/src/api/http-client.ts index 3556ae9..fd0b511 100644 --- a/web/src/api/http-client.ts +++ b/web/src/api/http-client.ts @@ -21,8 +21,10 @@ export const httpClient = axios.create({ }) httpClient.interceptors.request.use(async (config) => { - // Only add the Authorization header to requests that start with "/api" - if (config.url?.startsWith("/api")) { + // Only add the Authorization header to authenticated "/api" requests. + // "/api/public" routes are mounted ahead of the auth middleware and must + // stay reachable for anonymous visitors, so skip attaching a token there. + if (config.url?.startsWith("/api") && !config.url?.startsWith("/api/public")) { const accessToken = await auth0.getAccessTokenSilently() config.headers["Authorization"] = `Bearer ${accessToken}` } diff --git a/web/src/api/public/blog-posts-api.ts b/web/src/api/public/blog-posts-api.ts new file mode 100644 index 0000000..2909001 --- /dev/null +++ b/web/src/api/public/blog-posts-api.ts @@ -0,0 +1,48 @@ +import http from "@/api/http-client" +import { type FiltersOptions, type ModelOrder, type WhereOptions } from "@/api/base-api" + +export type PublicBlogPost = { + id: number + creatorId: number + title: string + slug: string + content: string + tags: string[] + publishedAt: string | null + createdAt: string + updatedAt: string +} + +export type PublicBlogPostIndexView = Omit + +export type PublicBlogPostWhereOptions = WhereOptions + +export type PublicBlogPostFiltersOptions = FiltersOptions<{ + search: string | string[] +}> + +export type PublicBlogPostQueryOptions = { + where?: PublicBlogPostWhereOptions + filters?: PublicBlogPostFiltersOptions + order?: ModelOrder[] + page?: number + perPage?: number +} + +export const publicBlogPostsApi = { + async list(params: PublicBlogPostQueryOptions = {}): Promise<{ + blogPosts: PublicBlogPostIndexView[] + totalCount: number + }> { + const { data } = await http.get("/api/public/blog-posts", { params }) + return data + }, + async get(blogPostIdOrSlug: number | string): Promise<{ + blogPost: PublicBlogPost + }> { + const { data } = await http.get(`/api/public/blog-posts/${blogPostIdOrSlug}`) + return data + }, +} + +export default publicBlogPostsApi diff --git a/web/src/api/public/index.ts b/web/src/api/public/index.ts new file mode 100644 index 0000000..7b110bb --- /dev/null +++ b/web/src/api/public/index.ts @@ -0,0 +1 @@ +export { publicBlogPostsApi } from "./blog-posts-api" diff --git a/web/src/api/users/index.ts b/web/src/api/users/index.ts new file mode 100644 index 0000000..388b9af --- /dev/null +++ b/web/src/api/users/index.ts @@ -0,0 +1 @@ +export { preferencesApi } from "./preferences-api" diff --git a/web/src/api/users/preferences-api.ts b/web/src/api/users/preferences-api.ts new file mode 100644 index 0000000..f646249 --- /dev/null +++ b/web/src/api/users/preferences-api.ts @@ -0,0 +1,32 @@ +import http from "@/api/http-client" + +/** Keep in sync with api/src/models/user-preference.ts */ +export enum PreferenceKeys { + FLASHCARDS_DAY_STREAK = "flashcards.day_streak", +} + +export type PreferenceValueMap = { + [PreferenceKeys.FLASHCARDS_DAY_STREAK]: number +} + +export type Preference = { + key: PreferenceKeys + value: unknown | null +} + +export type PreferenceAsShow = Preference + +export const preferencesApi = { + async upsert( + userId: number, + key: K, + value: PreferenceValueMap[K] | null + ): Promise<{ message: string }> { + const { data } = await http.patch(`/api/users/${userId}/preferences/${key}`, { + value, + }) + return data + }, +} + +export default preferencesApi diff --git a/web/src/components/blog-posts/BlogPostListItemCard.vue b/web/src/components/blog-posts/BlogPostListItemCard.vue new file mode 100644 index 0000000..eb8fd6f --- /dev/null +++ b/web/src/components/blog-posts/BlogPostListItemCard.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/web/src/components/blog-posts/PublicBlogPostsList.vue b/web/src/components/blog-posts/PublicBlogPostsList.vue new file mode 100644 index 0000000..6985f39 --- /dev/null +++ b/web/src/components/blog-posts/PublicBlogPostsList.vue @@ -0,0 +1,67 @@ + + + diff --git a/web/src/components/common/DashboardCard.vue b/web/src/components/common/DashboardCard.vue new file mode 100644 index 0000000..c1be3e3 --- /dev/null +++ b/web/src/components/common/DashboardCard.vue @@ -0,0 +1,55 @@ + + + diff --git a/web/src/components/common/PublicAppLogo.vue b/web/src/components/common/PublicAppLogo.vue index 7066d28..dd9f108 100644 --- a/web/src/components/common/PublicAppLogo.vue +++ b/web/src/components/common/PublicAppLogo.vue @@ -8,86 +8,20 @@ caleb @ burke - ~ - $ - {{ typedText }} - - █ - diff --git a/web/src/components/layout/PublicAppBar.vue b/web/src/components/layout/PublicAppBar.vue index 531836f..6d73a53 100644 --- a/web/src/components/layout/PublicAppBar.vue +++ b/web/src/components/layout/PublicAppBar.vue @@ -30,10 +30,7 @@ class="main-head pl-2" >
- +
@@ -51,17 +48,12 @@ diff --git a/web/src/layouts/PublicLayout.vue b/web/src/layouts/PublicLayout.vue index b1100e3..79d419c 100644 --- a/web/src/layouts/PublicLayout.vue +++ b/web/src/layouts/PublicLayout.vue @@ -1,22 +1,232 @@ + + diff --git a/web/src/pages/BlogPage.vue b/web/src/pages/BlogPage.vue new file mode 100644 index 0000000..130b44c --- /dev/null +++ b/web/src/pages/BlogPage.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/web/src/pages/DashboardPage.vue b/web/src/pages/DashboardPage.vue index 3a1092f..b6de2e1 100644 --- a/web/src/pages/DashboardPage.vue +++ b/web/src/pages/DashboardPage.vue @@ -4,15 +4,70 @@ You are a system admin + + + + + + + + + + + + + diff --git a/web/src/pages/FlashcardsPage.vue b/web/src/pages/FlashcardsPage.vue index 8dea0da..f47d2c9 100644 --- a/web/src/pages/FlashcardsPage.vue +++ b/web/src/pages/FlashcardsPage.vue @@ -154,5 +154,12 @@ function openFlashcardCreateDialog() { flashcardCreateDialog.value?.show() } -useBreadcrumbs("Study") +useBreadcrumbs("Flashcards", [ + { + title: "Flashcards", + to: { + name: "FlashcardsPage", + }, + }, +]) diff --git a/web/src/pages/HomePage.vue b/web/src/pages/HomePage.vue index 71f6a71..5701e9f 100644 --- a/web/src/pages/HomePage.vue +++ b/web/src/pages/HomePage.vue @@ -1,260 +1,179 @@