From 535c4c49434b3cc1027f8ba00aa84b03b023490a Mon Sep 17 00:00:00 2001 From: Caleb Burke Date: Thu, 25 Jun 2026 02:39:16 -0700 Subject: [PATCH] Adding in flashcard's and decks --- .../controllers/flashcard-decks-controller.ts | 123 ++++++++++++++++++ api/src/controllers/flashcards-controller.ts | 123 ++++++++++++++++++ api/src/controllers/index.ts | 2 + ...0625090034_create-flashcard-decks-table.ts | 27 ++++ .../20260625090042_create-flashcards-table.ts | 29 +++++ api/src/models/flashcard-deck.ts | 68 ++++++++++ api/src/models/flashcard.ts | 81 ++++++++++++ api/src/models/index.ts | 8 ++ api/src/policies/flashcard-decks-policy.ts | 41 ++++++ api/src/policies/flashcards-policy.ts | 41 ++++++ api/src/policies/index.ts | 2 + api/src/router.ts | 22 +++- .../flashcard-decks/index-serializer.ts | 17 +++ api/src/serializers/flashcard-decks/index.ts | 2 + .../flashcard-decks/show-serializer.ts | 17 +++ .../flashcards/index-serializer.ts | 26 ++++ api/src/serializers/flashcards/index.ts | 2 + .../serializers/flashcards/show-serializer.ts | 26 ++++ api/src/serializers/index.ts | 2 + .../flashcard-decks/create-service.ts | 33 +++++ .../flashcard-decks/destroy-service.ts | 14 ++ api/src/services/flashcard-decks/index.ts | 3 + .../flashcard-decks/update-service.ts | 21 +++ api/src/services/flashcards/create-service.ts | 43 ++++++ .../services/flashcards/destroy-service.ts | 14 ++ api/src/services/flashcards/index.ts | 3 + api/src/services/flashcards/update-service.ts | 21 +++ api/src/services/index.ts | 2 + web/src/api/flashcard-decks-api.ts | 63 +++++++++ web/src/api/flashcards-api.ts | 65 +++++++++ web/src/use/use-flashcard-deck.ts | 87 +++++++++++++ web/src/use/use-flashcard-decks.ts | 67 ++++++++++ web/src/use/use-flashcard.ts | 87 +++++++++++++ web/src/use/use-flashcards.ts | 67 ++++++++++ 34 files changed, 1248 insertions(+), 1 deletion(-) create mode 100644 api/src/controllers/flashcard-decks-controller.ts create mode 100644 api/src/controllers/flashcards-controller.ts create mode 100644 api/src/db/migrations/20260625090034_create-flashcard-decks-table.ts create mode 100644 api/src/db/migrations/20260625090042_create-flashcards-table.ts create mode 100644 api/src/models/flashcard-deck.ts create mode 100644 api/src/models/flashcard.ts create mode 100644 api/src/policies/flashcard-decks-policy.ts create mode 100644 api/src/policies/flashcards-policy.ts create mode 100644 api/src/serializers/flashcard-decks/index-serializer.ts create mode 100644 api/src/serializers/flashcard-decks/index.ts create mode 100644 api/src/serializers/flashcard-decks/show-serializer.ts create mode 100644 api/src/serializers/flashcards/index-serializer.ts create mode 100644 api/src/serializers/flashcards/index.ts create mode 100644 api/src/serializers/flashcards/show-serializer.ts create mode 100644 api/src/services/flashcard-decks/create-service.ts create mode 100644 api/src/services/flashcard-decks/destroy-service.ts create mode 100644 api/src/services/flashcard-decks/index.ts create mode 100644 api/src/services/flashcard-decks/update-service.ts create mode 100644 api/src/services/flashcards/create-service.ts create mode 100644 api/src/services/flashcards/destroy-service.ts create mode 100644 api/src/services/flashcards/index.ts create mode 100644 api/src/services/flashcards/update-service.ts create mode 100644 web/src/api/flashcard-decks-api.ts create mode 100644 web/src/api/flashcards-api.ts create mode 100644 web/src/use/use-flashcard-deck.ts create mode 100644 web/src/use/use-flashcard-decks.ts create mode 100644 web/src/use/use-flashcard.ts create mode 100644 web/src/use/use-flashcards.ts diff --git a/api/src/controllers/flashcard-decks-controller.ts b/api/src/controllers/flashcard-decks-controller.ts new file mode 100644 index 0000000..d07c519 --- /dev/null +++ b/api/src/controllers/flashcard-decks-controller.ts @@ -0,0 +1,123 @@ +import { isNil } from "lodash" + +import logger from "@/utils/logger" +import { FlashcardDeck } from "@/models" +import { FlashcardDecksPolicy } from "@/policies" +import { CreateService, DestroyService, UpdateService } from "@/services/flashcard-decks" +import { IndexSerializer, ShowSerializer } from "@/serializers/flashcard-decks" +import BaseController from "@/controllers/base-controller" + +export class FlashcardDecksController extends BaseController { + async index() { + try { + const where = this.buildWhere() + const scopes = this.buildFilterScopes() + const scopedDecks = FlashcardDecksPolicy.applyScope(scopes, this.currentUser) + + const totalCount = await scopedDecks.count({ where }) + const flashcardDecks = await scopedDecks.findAll({ + where, + limit: this.pagination.limit, + offset: this.pagination.offset, + order: this.buildOrder(), + }) + const serializedDecks = IndexSerializer.perform(flashcardDecks) + return this.response.json({ flashcardDecks: serializedDecks, totalCount }) + } catch (error) { + logger.error("Error fetching flashcard decks" + error) + return this.response.status(400).json({ message: `Error fetching flashcard decks: ${error}` }) + } + } + + async show() { + try { + const flashcardDeck = await this.loadFlashcardDeck() + if (isNil(flashcardDeck)) { + return this.response.status(404).json({ message: "Flashcard deck not found" }) + } + + const policy = this.buildPolicy(flashcardDeck) + if (!policy.show()) { + return this.response.status(403).json({ message: "You are not authorized to view this flashcard deck" }) + } + + const serializedDeck = ShowSerializer.perform(flashcardDeck) + return this.response.json({ flashcardDeck: serializedDeck, policy }) + } catch (error) { + logger.error("Error fetching flashcard deck" + error) + return this.response.status(400).json({ message: `Error fetching flashcard deck: ${error}` }) + } + } + + async create() { + try { + const policy = this.buildPolicy() + if (!policy.create()) { + return this.response.status(403).json({ message: "You are not authorized to create flashcard decks" }) + } + + const permittedAttributes = policy.permitAttributesForCreate(this.request.body) + const flashcardDeck = await CreateService.perform({ + ...permittedAttributes, + creatorId: this.currentUser.id, + }) + const serializedDeck = ShowSerializer.perform(flashcardDeck) + return this.response.status(201).json({ flashcardDeck: serializedDeck }) + } catch (error) { + logger.error("Error creating flashcard deck" + error) + return this.response.status(422).json({ message: `Error creating flashcard deck: ${error}` }) + } + } + + async update() { + try { + const flashcardDeck = await this.loadFlashcardDeck() + if (isNil(flashcardDeck)) { + return this.response.status(404).json({ message: "Flashcard deck not found" }) + } + + const policy = this.buildPolicy(flashcardDeck) + if (!policy.update()) { + return this.response.status(403).json({ message: "You are not authorized to update this flashcard deck" }) + } + + const permittedAttributes = policy.permitAttributes(this.request.body) + const updatedDeck = await UpdateService.perform(flashcardDeck, permittedAttributes) + const serializedDeck = ShowSerializer.perform(updatedDeck) + return this.response.json({ flashcardDeck: serializedDeck }) + } catch (error) { + logger.error("Error updating flashcard deck" + error) + return this.response.status(422).json({ message: `Error updating flashcard deck: ${error}` }) + } + } + + async destroy() { + try { + const flashcardDeck = await this.loadFlashcardDeck() + if (isNil(flashcardDeck)) { + return this.response.status(404).json({ message: "Flashcard deck not found" }) + } + + const policy = this.buildPolicy(flashcardDeck) + if (!policy.destroy()) { + return this.response.status(403).json({ message: "You are not authorized to delete this flashcard deck" }) + } + + await DestroyService.perform(flashcardDeck) + return this.response.status(204).send() + } catch (error) { + logger.error("Error deleting flashcard deck" + error) + return this.response.status(422).json({ message: `Error deleting flashcard deck: ${error}` }) + } + } + + private async loadFlashcardDeck() { + return FlashcardDeck.findByPk(this.params.flashcardDeckId) + } + + private buildPolicy(flashcardDeck: FlashcardDeck = FlashcardDeck.build()) { + return new FlashcardDecksPolicy(this.currentUser, flashcardDeck) + } +} + +export default FlashcardDecksController diff --git a/api/src/controllers/flashcards-controller.ts b/api/src/controllers/flashcards-controller.ts new file mode 100644 index 0000000..68350cd --- /dev/null +++ b/api/src/controllers/flashcards-controller.ts @@ -0,0 +1,123 @@ +import { isNil } from "lodash" + +import logger from "@/utils/logger" +import { Flashcard } from "@/models" +import { FlashcardsPolicy } from "@/policies" +import { CreateService, DestroyService, UpdateService } from "@/services/flashcards" +import { IndexSerializer, ShowSerializer } from "@/serializers/flashcards" +import BaseController from "@/controllers/base-controller" + +export class FlashcardsController extends BaseController { + async index() { + try { + const where = this.buildWhere() + const scopes = this.buildFilterScopes() + const scopedFlashcards = FlashcardsPolicy.applyScope(scopes, this.currentUser) + + const totalCount = await scopedFlashcards.count({ where }) + const flashcards = await scopedFlashcards.findAll({ + where, + limit: this.pagination.limit, + offset: this.pagination.offset, + order: this.buildOrder(), + }) + const serializedFlashcards = IndexSerializer.perform(flashcards) + return this.response.json({ flashcards: serializedFlashcards, totalCount }) + } catch (error) { + logger.error("Error fetching flashcards" + error) + return this.response.status(400).json({ message: `Error fetching flashcards: ${error}` }) + } + } + + async show() { + try { + const flashcard = await this.loadFlashcard() + if (isNil(flashcard)) { + return this.response.status(404).json({ message: "Flashcard not found" }) + } + + const policy = this.buildPolicy(flashcard) + if (!policy.show()) { + return this.response.status(403).json({ message: "You are not authorized to view this flashcard" }) + } + + const serializedFlashcard = ShowSerializer.perform(flashcard) + return this.response.json({ flashcard: serializedFlashcard, policy }) + } catch (error) { + logger.error("Error fetching flashcard" + error) + return this.response.status(400).json({ message: `Error fetching flashcard: ${error}` }) + } + } + + async create() { + try { + const policy = this.buildPolicy() + if (!policy.create()) { + return this.response.status(403).json({ message: "You are not authorized to create flashcards" }) + } + + const permittedAttributes = policy.permitAttributesForCreate(this.request.body) + const flashcard = await CreateService.perform({ + ...permittedAttributes, + creatorId: this.currentUser.id, + }) + const serializedFlashcard = ShowSerializer.perform(flashcard) + return this.response.status(201).json({ flashcard: serializedFlashcard }) + } catch (error) { + logger.error("Error creating flashcard" + error) + return this.response.status(422).json({ message: `Error creating flashcard: ${error}` }) + } + } + + async update() { + try { + const flashcard = await this.loadFlashcard() + if (isNil(flashcard)) { + return this.response.status(404).json({ message: "Flashcard not found" }) + } + + const policy = this.buildPolicy(flashcard) + if (!policy.update()) { + return this.response.status(403).json({ message: "You are not authorized to update this flashcard" }) + } + + const permittedAttributes = policy.permitAttributes(this.request.body) + const updatedFlashcard = await UpdateService.perform(flashcard, permittedAttributes) + const serializedFlashcard = ShowSerializer.perform(updatedFlashcard) + return this.response.json({ flashcard: serializedFlashcard }) + } catch (error) { + logger.error("Error updating flashcard" + error) + return this.response.status(422).json({ message: `Error updating flashcard: ${error}` }) + } + } + + async destroy() { + try { + const flashcard = await this.loadFlashcard() + if (isNil(flashcard)) { + return this.response.status(404).json({ message: "Flashcard not found" }) + } + + const policy = this.buildPolicy(flashcard) + if (!policy.destroy()) { + return this.response.status(403).json({ message: "You are not authorized to delete this flashcard" }) + } + + await DestroyService.perform(flashcard) + return this.response.status(204).send() + } catch (error) { + logger.error("Error deleting flashcard" + error) + return this.response.status(422).json({ message: `Error deleting flashcard: ${error}` }) + } + } + + private async loadFlashcard() { + return Flashcard.findByPk(this.params.flashcardId) + } + + private buildPolicy(flashcard: Flashcard = Flashcard.build()) { + return new FlashcardsPolicy(this.currentUser, flashcard) + } +} + +export default FlashcardsController diff --git a/api/src/controllers/index.ts b/api/src/controllers/index.ts index f88538f..ae73d6e 100644 --- a/api/src/controllers/index.ts +++ b/api/src/controllers/index.ts @@ -1,3 +1,5 @@ // Controllers export { CurrentUserController } from "./current-user-controller" +export { FlashcardDecksController } from "./flashcard-decks-controller" +export { FlashcardsController } from "./flashcards-controller" export { UsersController } from "./users-controller" diff --git a/api/src/db/migrations/20260625090034_create-flashcard-decks-table.ts b/api/src/db/migrations/20260625090034_create-flashcard-decks-table.ts new file mode 100644 index 0000000..ddb78a1 --- /dev/null +++ b/api/src/db/migrations/20260625090034_create-flashcard-decks-table.ts @@ -0,0 +1,27 @@ +import type { Knex } from "knex" + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("flashcard_decks", function (table) { + table.increments("id").notNullable().primary() + table.integer("parent_deck_id").nullable() + table.integer("creator_id").notNullable() + table.string("name", 255).notNullable() + + 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("parent_deck_id").references("flashcard_decks.id") + table.foreign("creator_id").references("users.id") + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTable("flashcard_decks") +} diff --git a/api/src/db/migrations/20260625090042_create-flashcards-table.ts b/api/src/db/migrations/20260625090042_create-flashcards-table.ts new file mode 100644 index 0000000..ff85796 --- /dev/null +++ b/api/src/db/migrations/20260625090042_create-flashcards-table.ts @@ -0,0 +1,29 @@ +import type { Knex } from "knex" + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("flashcards", function (table) { + table.increments("id").notNullable().primary() + table.integer("flashcard_deck_id").notNullable() + table.integer("creator_id").notNullable() + table.string("card_type", 255).notNullable() + table.text("front").notNullable() + table.text("back").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("flashcard_deck_id").references("flashcard_decks.id") + table.foreign("creator_id").references("users.id") + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTable("flashcards") +} diff --git a/api/src/models/flashcard-deck.ts b/api/src/models/flashcard-deck.ts new file mode 100644 index 0000000..023861f --- /dev/null +++ b/api/src/models/flashcard-deck.ts @@ -0,0 +1,68 @@ +import { + type CreationOptional, + DataTypes, + InferAttributes, + InferCreationAttributes, + type NonAttribute, + sql, +} from "@sequelize/core" +import { + Attribute, + AutoIncrement, + BelongsTo, + Default, + NotNull, + PrimaryKey, +} from "@sequelize/core/decorators-legacy" + +import BaseModel from "@/models/base-model" + +export class FlashcardDeck extends BaseModel< + InferAttributes, + InferCreationAttributes +> { + @Attribute(DataTypes.INTEGER) + @PrimaryKey + @AutoIncrement + declare id: CreationOptional + + @Attribute(DataTypes.INTEGER) + declare parentDeckId: number | null + + @Attribute(DataTypes.INTEGER) + @NotNull + declare creatorId: number + + @Attribute(DataTypes.STRING(255)) + @NotNull + declare name: string + + @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(() => FlashcardDeck, { + foreignKey: { + name: "parentDeckId", + allowNull: true, + }, + }) + declare parentDeck?: NonAttribute + + // Scopes + static establishScopes(): void { + this.addSearchScope(["name"]) + } +} + +export default FlashcardDeck diff --git a/api/src/models/flashcard.ts b/api/src/models/flashcard.ts new file mode 100644 index 0000000..ab82c71 --- /dev/null +++ b/api/src/models/flashcard.ts @@ -0,0 +1,81 @@ +import { + type CreationOptional, + DataTypes, + InferAttributes, + InferCreationAttributes, + type NonAttribute, + sql, +} from "@sequelize/core" +import { + Attribute, + AutoIncrement, + BelongsTo, + Default, + NotNull, + PrimaryKey, +} from "@sequelize/core/decorators-legacy" + +import BaseModel from "@/models/base-model" +import FlashcardDeck from "@/models/flashcard-deck" + +export class Flashcard extends BaseModel< + InferAttributes, + InferCreationAttributes +> { + @Attribute(DataTypes.INTEGER) + @PrimaryKey + @AutoIncrement + declare id: CreationOptional + + @Attribute(DataTypes.INTEGER) + @NotNull + declare flashcardDeckId: number + + @Attribute(DataTypes.INTEGER) + @NotNull + declare creatorId: number + + @Attribute(DataTypes.STRING(255)) + @NotNull + declare cardType: string + + @Attribute(DataTypes.TEXT) + @NotNull + declare front: string + + @Attribute(DataTypes.TEXT) + declare back: string | 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(() => FlashcardDeck, { + foreignKey: { + name: "flashcardDeckId", + allowNull: false, + }, + inverse: { + as: "flashcards", + type: "hasMany", + }, + }) + declare flashcardDeck?: NonAttribute + + // Scopes + static establishScopes(): void { + this.addSearchScope(["front", "back"]) + } +} + +export default Flashcard diff --git a/api/src/models/index.ts b/api/src/models/index.ts index a67a441..75a6508 100644 --- a/api/src/models/index.ts +++ b/api/src/models/index.ts @@ -1,16 +1,24 @@ import db from "@/db/db-client" // Models +import Flashcard from "@/models/flashcard" +import FlashcardDeck from "@/models/flashcard-deck" import User, { UserRoles } from "@/models/user" db.addModels([ + Flashcard, + FlashcardDeck, User, ]) // Lazy load scopes +Flashcard.establishScopes() +FlashcardDeck.establishScopes() User.establishScopes() export { + Flashcard, + FlashcardDeck, User, UserRoles, } diff --git a/api/src/policies/flashcard-decks-policy.ts b/api/src/policies/flashcard-decks-policy.ts new file mode 100644 index 0000000..d4f0cae --- /dev/null +++ b/api/src/policies/flashcard-decks-policy.ts @@ -0,0 +1,41 @@ +import { Attributes, FindOptions } from "@sequelize/core" + +import { FlashcardDeck, User } from "@/models" +import { ALL_RECORDS_SCOPE, PolicyFactory } from "@/policies/base-policy" +import { Path } from "@/utils/deep-pick" + +export class FlashcardDecksPolicy extends PolicyFactory(FlashcardDeck) { + show(): boolean { + return true + } + + create(): boolean { + return true + } + + update(): boolean { + return this.user.id === this.record.creatorId + } + + destroy(): boolean { + return this.user.id === this.record.creatorId + } + + permittedAttributes(): Path[] { + return ["name", "parentDeckId"] as (keyof Attributes)[] + } + + permittedAttributesForCreate(): Path[] { + return [...this.permittedAttributes()] + } + + permittedAttributesForUpdate(): Path[] { + return [...this.permittedAttributes()] + } + + static policyScope(_user: User): FindOptions> { + return ALL_RECORDS_SCOPE + } +} + +export default FlashcardDecksPolicy diff --git a/api/src/policies/flashcards-policy.ts b/api/src/policies/flashcards-policy.ts new file mode 100644 index 0000000..b636260 --- /dev/null +++ b/api/src/policies/flashcards-policy.ts @@ -0,0 +1,41 @@ +import { Attributes, FindOptions } from "@sequelize/core" + +import { Flashcard, User } from "@/models" +import { ALL_RECORDS_SCOPE, PolicyFactory } from "@/policies/base-policy" +import { Path } from "@/utils/deep-pick" + +export class FlashcardsPolicy extends PolicyFactory(Flashcard) { + show(): boolean { + return true + } + + create(): boolean { + return true + } + + update(): boolean { + return this.user.id === this.record.creatorId + } + + destroy(): boolean { + return this.user.id === this.record.creatorId + } + + permittedAttributes(): Path[] { + return ["flashcardDeckId", "cardType", "front", "back"] as (keyof Attributes)[] + } + + permittedAttributesForCreate(): Path[] { + return [...this.permittedAttributes()] + } + + permittedAttributesForUpdate(): Path[] { + return [...this.permittedAttributes()] + } + + static policyScope(_user: User): FindOptions> { + return ALL_RECORDS_SCOPE + } +} + +export default FlashcardsPolicy diff --git a/api/src/policies/index.ts b/api/src/policies/index.ts index 7021cf6..04a4724 100644 --- a/api/src/policies/index.ts +++ b/api/src/policies/index.ts @@ -1,3 +1,5 @@ // Policy Bundles export { type BaseScopeOptions } from "./base-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 f00ec6d..402a1fc 100644 --- a/api/src/router.ts +++ b/api/src/router.ts @@ -16,7 +16,7 @@ import { logger } from "@/utils/logger" import { jwtMiddleware, authorizationMiddleware } from "@/middlewares" -import { CurrentUserController, UsersController } from "@/controllers" +import { CurrentUserController, FlashcardDecksController, FlashcardsController, UsersController } from "@/controllers" export const router = Router() @@ -42,6 +42,26 @@ router .patch(UsersController.update) .delete(UsersController.destroy) +router + .route("/api/flashcard-decks") + .get(FlashcardDecksController.index) + .post(FlashcardDecksController.create) +router + .route("/api/flashcard-decks/:flashcardDeckId") + .get(FlashcardDecksController.show) + .patch(FlashcardDecksController.update) + .delete(FlashcardDecksController.destroy) + +router + .route("/api/flashcards") + .get(FlashcardsController.index) + .post(FlashcardsController.create) +router + .route("/api/flashcards/:flashcardId") + .get(FlashcardsController.show) + .patch(FlashcardsController.update) + .delete(FlashcardsController.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/flashcard-decks/index-serializer.ts b/api/src/serializers/flashcard-decks/index-serializer.ts new file mode 100644 index 0000000..9624af7 --- /dev/null +++ b/api/src/serializers/flashcard-decks/index-serializer.ts @@ -0,0 +1,17 @@ +import { pick } from "lodash" + +import { FlashcardDeck } from "@/models" +import BaseSerializer from "@/serializers/base-serializer" + +export type FlashcardDeckIndexView = Pick< + FlashcardDeck, + "id" | "parentDeckId" | "creatorId" | "name" | "createdAt" | "updatedAt" +> + +export class IndexSerializer extends BaseSerializer { + perform(): FlashcardDeckIndexView { + return pick(this.record, ["id", "parentDeckId", "creatorId", "name", "createdAt", "updatedAt"]) + } +} + +export default IndexSerializer diff --git a/api/src/serializers/flashcard-decks/index.ts b/api/src/serializers/flashcard-decks/index.ts new file mode 100644 index 0000000..8f3b8f6 --- /dev/null +++ b/api/src/serializers/flashcard-decks/index.ts @@ -0,0 +1,2 @@ +export { IndexSerializer } from "./index-serializer" +export { ShowSerializer } from "./show-serializer" diff --git a/api/src/serializers/flashcard-decks/show-serializer.ts b/api/src/serializers/flashcard-decks/show-serializer.ts new file mode 100644 index 0000000..3f60566 --- /dev/null +++ b/api/src/serializers/flashcard-decks/show-serializer.ts @@ -0,0 +1,17 @@ +import { pick } from "lodash" + +import { FlashcardDeck } from "@/models" +import BaseSerializer from "@/serializers/base-serializer" + +export type FlashcardDeckShowView = Pick< + FlashcardDeck, + "id" | "parentDeckId" | "creatorId" | "name" | "createdAt" | "updatedAt" +> + +export class ShowSerializer extends BaseSerializer { + perform(): FlashcardDeckShowView { + return pick(this.record, ["id", "parentDeckId", "creatorId", "name", "createdAt", "updatedAt"]) + } +} + +export default ShowSerializer diff --git a/api/src/serializers/flashcards/index-serializer.ts b/api/src/serializers/flashcards/index-serializer.ts new file mode 100644 index 0000000..b149c2e --- /dev/null +++ b/api/src/serializers/flashcards/index-serializer.ts @@ -0,0 +1,26 @@ +import { pick } from "lodash" + +import { Flashcard } from "@/models" +import BaseSerializer from "@/serializers/base-serializer" + +export type FlashcardIndexView = Pick< + Flashcard, + "id" | "flashcardDeckId" | "creatorId" | "cardType" | "front" | "back" | "createdAt" | "updatedAt" +> + +export class IndexSerializer extends BaseSerializer { + perform(): FlashcardIndexView { + return pick(this.record, [ + "id", + "flashcardDeckId", + "creatorId", + "cardType", + "front", + "back", + "createdAt", + "updatedAt", + ]) + } +} + +export default IndexSerializer diff --git a/api/src/serializers/flashcards/index.ts b/api/src/serializers/flashcards/index.ts new file mode 100644 index 0000000..8f3b8f6 --- /dev/null +++ b/api/src/serializers/flashcards/index.ts @@ -0,0 +1,2 @@ +export { IndexSerializer } from "./index-serializer" +export { ShowSerializer } from "./show-serializer" diff --git a/api/src/serializers/flashcards/show-serializer.ts b/api/src/serializers/flashcards/show-serializer.ts new file mode 100644 index 0000000..7b73169 --- /dev/null +++ b/api/src/serializers/flashcards/show-serializer.ts @@ -0,0 +1,26 @@ +import { pick } from "lodash" + +import { Flashcard } from "@/models" +import BaseSerializer from "@/serializers/base-serializer" + +export type FlashcardShowView = Pick< + Flashcard, + "id" | "flashcardDeckId" | "creatorId" | "cardType" | "front" | "back" | "createdAt" | "updatedAt" +> + +export class ShowSerializer extends BaseSerializer { + perform(): FlashcardShowView { + return pick(this.record, [ + "id", + "flashcardDeckId", + "creatorId", + "cardType", + "front", + "back", + "createdAt", + "updatedAt", + ]) + } +} + +export default ShowSerializer diff --git a/api/src/serializers/index.ts b/api/src/serializers/index.ts index f5381be..df02417 100644 --- a/api/src/serializers/index.ts +++ b/api/src/serializers/index.ts @@ -1,2 +1,4 @@ // Bundled exports +export * as FlashcardDecks from "./flashcard-decks" +export * as Flashcards from "./flashcards" export * as Users from "./users" diff --git a/api/src/services/flashcard-decks/create-service.ts b/api/src/services/flashcard-decks/create-service.ts new file mode 100644 index 0000000..60b6991 --- /dev/null +++ b/api/src/services/flashcard-decks/create-service.ts @@ -0,0 +1,33 @@ +import { CreationAttributes } from "@sequelize/core" +import { isNil } from "lodash" + +import { FlashcardDeck } from "@/models" +import BaseService from "@/services/base-service" + +export type FlashcardDeckCreationAttributes = Partial> + +export class CreateService extends BaseService { + constructor(private attributes: FlashcardDeckCreationAttributes) { + super() + } + + async perform(): Promise { + const { creatorId, name, ...optionalAttributes } = this.attributes + + if (isNil(creatorId)) { + throw new Error("Creator is required") + } + + if (isNil(name)) { + throw new Error("Name is required") + } + + return FlashcardDeck.create({ + ...optionalAttributes, + creatorId, + name, + }) + } +} + +export default CreateService diff --git a/api/src/services/flashcard-decks/destroy-service.ts b/api/src/services/flashcard-decks/destroy-service.ts new file mode 100644 index 0000000..618a459 --- /dev/null +++ b/api/src/services/flashcard-decks/destroy-service.ts @@ -0,0 +1,14 @@ +import { FlashcardDeck } from "@/models" +import BaseService from "@/services/base-service" + +export class DestroyService extends BaseService { + constructor(private flashcardDeck: FlashcardDeck) { + super() + } + + async perform(): Promise { + return this.flashcardDeck.destroy() + } +} + +export default DestroyService diff --git a/api/src/services/flashcard-decks/index.ts b/api/src/services/flashcard-decks/index.ts new file mode 100644 index 0000000..6aaf3e5 --- /dev/null +++ b/api/src/services/flashcard-decks/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/flashcard-decks/update-service.ts b/api/src/services/flashcard-decks/update-service.ts new file mode 100644 index 0000000..4e931c3 --- /dev/null +++ b/api/src/services/flashcard-decks/update-service.ts @@ -0,0 +1,21 @@ +import { Attributes } from "@sequelize/core" + +import { FlashcardDeck } from "@/models" +import BaseService from "@/services/base-service" + +export type FlashcardDeckUpdateAttributes = Partial> + +export class UpdateService extends BaseService { + constructor( + private flashcardDeck: FlashcardDeck, + private attributes: FlashcardDeckUpdateAttributes + ) { + super() + } + + async perform(): Promise { + return this.flashcardDeck.update(this.attributes) + } +} + +export default UpdateService diff --git a/api/src/services/flashcards/create-service.ts b/api/src/services/flashcards/create-service.ts new file mode 100644 index 0000000..836e243 --- /dev/null +++ b/api/src/services/flashcards/create-service.ts @@ -0,0 +1,43 @@ +import { CreationAttributes } from "@sequelize/core" +import { isNil } from "lodash" + +import { Flashcard } from "@/models" +import BaseService from "@/services/base-service" + +export type FlashcardCreationAttributes = Partial> + +export class CreateService extends BaseService { + constructor(private attributes: FlashcardCreationAttributes) { + super() + } + + async perform(): Promise { + const { flashcardDeckId, creatorId, cardType, front, ...optionalAttributes } = this.attributes + + if (isNil(flashcardDeckId)) { + throw new Error("Flashcard deck is required") + } + + if (isNil(creatorId)) { + throw new Error("Creator is required") + } + + if (isNil(cardType)) { + throw new Error("Card type is required") + } + + if (isNil(front)) { + throw new Error("Front is required") + } + + return Flashcard.create({ + ...optionalAttributes, + flashcardDeckId, + creatorId, + cardType, + front, + }) + } +} + +export default CreateService diff --git a/api/src/services/flashcards/destroy-service.ts b/api/src/services/flashcards/destroy-service.ts new file mode 100644 index 0000000..c61b491 --- /dev/null +++ b/api/src/services/flashcards/destroy-service.ts @@ -0,0 +1,14 @@ +import { Flashcard } from "@/models" +import BaseService from "@/services/base-service" + +export class DestroyService extends BaseService { + constructor(private flashcard: Flashcard) { + super() + } + + async perform(): Promise { + return this.flashcard.destroy() + } +} + +export default DestroyService diff --git a/api/src/services/flashcards/index.ts b/api/src/services/flashcards/index.ts new file mode 100644 index 0000000..6aaf3e5 --- /dev/null +++ b/api/src/services/flashcards/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/flashcards/update-service.ts b/api/src/services/flashcards/update-service.ts new file mode 100644 index 0000000..1a19d17 --- /dev/null +++ b/api/src/services/flashcards/update-service.ts @@ -0,0 +1,21 @@ +import { Attributes } from "@sequelize/core" + +import { Flashcard } from "@/models" +import BaseService from "@/services/base-service" + +export type FlashcardUpdateAttributes = Partial> + +export class UpdateService extends BaseService { + constructor( + private flashcard: Flashcard, + private attributes: FlashcardUpdateAttributes + ) { + super() + } + + async perform(): Promise { + return this.flashcard.update(this.attributes) + } +} + +export default UpdateService diff --git a/api/src/services/index.ts b/api/src/services/index.ts index f769ed3..506cffc 100644 --- a/api/src/services/index.ts +++ b/api/src/services/index.ts @@ -1 +1,3 @@ +export * as FlashcardDecks from "./flashcard-decks" +export * as Flashcards from "./flashcards" export * as Users from "./users" diff --git a/web/src/api/flashcard-decks-api.ts b/web/src/api/flashcard-decks-api.ts new file mode 100644 index 0000000..f9ef785 --- /dev/null +++ b/web/src/api/flashcard-decks-api.ts @@ -0,0 +1,63 @@ +import http from "@/api/http-client" +import { type FiltersOptions, type ModelOrder, type Policy, type WhereOptions } from "@/api/base-api" + +export type FlashcardDeck = { + id: number + parentDeckId: number | null + creatorId: number + name: string + createdAt: string + updatedAt: string +} + +export type FlashcardDeckWhereOptions = WhereOptions + +export type FlashcardDeckFiltersOptions = FiltersOptions<{ + search: string | string[] +}> + +export type FlashcardDeckQueryOptions = { + where?: FlashcardDeckWhereOptions + filters?: FlashcardDeckFiltersOptions + order?: ModelOrder[] + page?: number + perPage?: number +} + +export const flashcardDecksApi = { + async list(params: FlashcardDeckQueryOptions = {}): Promise<{ + flashcardDecks: FlashcardDeck[] + totalCount: number + }> { + const { data } = await http.get("/api/flashcard-decks", { params }) + return data + }, + async get(flashcardDeckId: number): Promise<{ + flashcardDeck: FlashcardDeck + policy: Policy + }> { + const { data } = await http.get(`/api/flashcard-decks/${flashcardDeckId}`) + return data + }, + async create(attributes: Partial): Promise<{ + flashcardDeck: FlashcardDeck + }> { + const { data } = await http.post("/api/flashcard-decks", attributes) + return data + }, + async update( + flashcardDeckId: number, + attributes: Partial + ): Promise<{ + flashcardDeck: FlashcardDeck + }> { + const { data } = await http.patch(`/api/flashcard-decks/${flashcardDeckId}`, attributes) + return data + }, + async delete(flashcardDeckId: number): Promise { + const { data } = await http.delete(`/api/flashcard-decks/${flashcardDeckId}`) + return data + }, +} + +export default flashcardDecksApi diff --git a/web/src/api/flashcards-api.ts b/web/src/api/flashcards-api.ts new file mode 100644 index 0000000..acb8b38 --- /dev/null +++ b/web/src/api/flashcards-api.ts @@ -0,0 +1,65 @@ +import http from "@/api/http-client" +import { type FiltersOptions, type ModelOrder, type Policy, type WhereOptions } from "@/api/base-api" + +export type Flashcard = { + id: number + flashcardDeckId: number + creatorId: number + cardType: string + front: string + back: string | null + createdAt: string + updatedAt: string +} + +export type FlashcardWhereOptions = WhereOptions + +export type FlashcardFiltersOptions = FiltersOptions<{ + search: string | string[] +}> + +export type FlashcardQueryOptions = { + where?: FlashcardWhereOptions + filters?: FlashcardFiltersOptions + order?: ModelOrder[] + page?: number + perPage?: number +} + +export const flashcardsApi = { + async list(params: FlashcardQueryOptions = {}): Promise<{ + flashcards: Flashcard[] + totalCount: number + }> { + const { data } = await http.get("/api/flashcards", { params }) + return data + }, + async get(flashcardId: number): Promise<{ + flashcard: Flashcard + policy: Policy + }> { + const { data } = await http.get(`/api/flashcards/${flashcardId}`) + return data + }, + async create(attributes: Partial): Promise<{ + flashcard: Flashcard + }> { + const { data } = await http.post("/api/flashcards", attributes) + return data + }, + async update( + flashcardId: number, + attributes: Partial + ): Promise<{ + flashcard: Flashcard + }> { + const { data } = await http.patch(`/api/flashcards/${flashcardId}`, attributes) + return data + }, + async delete(flashcardId: number): Promise { + const { data } = await http.delete(`/api/flashcards/${flashcardId}`) + return data + }, +} + +export default flashcardsApi diff --git a/web/src/use/use-flashcard-deck.ts b/web/src/use/use-flashcard-deck.ts new file mode 100644 index 0000000..f94734b --- /dev/null +++ b/web/src/use/use-flashcard-deck.ts @@ -0,0 +1,87 @@ +import { type Ref, reactive, toRefs, unref, watch } from "vue" +import { isNil } from "lodash" + +import { type Policy } from "@/api/base-api" +import flashcardDecksApi, { type FlashcardDeck } from "@/api/flashcard-decks-api" + +export { type FlashcardDeck } + +export function useFlashcardDeck(id: Ref) { + const state = reactive<{ + flashcardDeck: FlashcardDeck | null + policy: Policy | null + isLoading: boolean + isErrored: boolean + }>({ + flashcardDeck: null, + policy: null, + isLoading: false, + isErrored: false, + }) + + async function fetch(): Promise { + const staticId = unref(id) + if (isNil(staticId)) { + throw new Error("id is required") + } + + state.isLoading = true + try { + const { flashcardDeck, policy } = await flashcardDecksApi.get(staticId) + state.isErrored = false + state.flashcardDeck = flashcardDeck + state.policy = policy + return flashcardDeck + } catch (error) { + console.error("Failed to fetch flashcard deck:", error) + state.isErrored = true + throw error + } finally { + state.isLoading = false + } + } + + async function save(): Promise { + const staticId = unref(id) + if (isNil(staticId)) { + throw new Error("id is required") + } + + if (isNil(state.flashcardDeck)) { + throw new Error("No flashcard deck to save") + } + + state.isLoading = true + try { + const { flashcardDeck } = await flashcardDecksApi.update(staticId, state.flashcardDeck) + state.isErrored = false + state.flashcardDeck = flashcardDeck + return flashcardDeck + } catch (error) { + console.error("Failed to save flashcard deck:", error) + state.isErrored = true + throw error + } finally { + state.isLoading = false + } + } + + watch( + () => unref(id), + async (newId) => { + if (isNil(newId)) return + + await fetch() + }, + { immediate: true } + ) + + return { + ...toRefs(state), + fetch, + refresh: fetch, + save, + } +} + +export default useFlashcardDeck diff --git a/web/src/use/use-flashcard-decks.ts b/web/src/use/use-flashcard-decks.ts new file mode 100644 index 0000000..7244435 --- /dev/null +++ b/web/src/use/use-flashcard-decks.ts @@ -0,0 +1,67 @@ +import { type Ref, reactive, toRefs, ref, unref, watch } from "vue" + +import flashcardDecksApi, { + type FlashcardDeck, + type FlashcardDeckWhereOptions, + type FlashcardDeckFiltersOptions, + type FlashcardDeckQueryOptions, +} from "@/api/flashcard-decks-api" + +export { + type FlashcardDeck, + type FlashcardDeckWhereOptions, + type FlashcardDeckFiltersOptions, + type FlashcardDeckQueryOptions, +} + +export function useFlashcardDecks( + queryOptions: Ref = ref({}), + { skipWatchIf = () => false }: { skipWatchIf?: () => boolean } = {} +) { + const state = reactive<{ + flashcardDecks: FlashcardDeck[] + totalCount: number + isLoading: boolean + isErrored: boolean + }>({ + flashcardDecks: [], + totalCount: 0, + isLoading: false, + isErrored: false, + }) + + async function fetch(): Promise { + state.isLoading = true + try { + const { flashcardDecks, totalCount } = await flashcardDecksApi.list(unref(queryOptions)) + state.isErrored = false + state.flashcardDecks = flashcardDecks + state.totalCount = totalCount + return flashcardDecks + } catch (error) { + console.error("Failed to fetch flashcard decks:", error) + state.isErrored = true + throw error + } finally { + state.isLoading = false + } + } + + watch( + () => [skipWatchIf(), unref(queryOptions)], + async ([skip]) => { + if (skip) return + + await fetch() + }, + { deep: true, immediate: true } + ) + + return { + ...toRefs(state), + fetch, + refresh: fetch, + } +} + +export default useFlashcardDecks diff --git a/web/src/use/use-flashcard.ts b/web/src/use/use-flashcard.ts new file mode 100644 index 0000000..21be6dc --- /dev/null +++ b/web/src/use/use-flashcard.ts @@ -0,0 +1,87 @@ +import { type Ref, reactive, toRefs, unref, watch } from "vue" +import { isNil } from "lodash" + +import { type Policy } from "@/api/base-api" +import flashcardsApi, { type Flashcard } from "@/api/flashcards-api" + +export { type Flashcard } + +export function useFlashcard(id: Ref) { + const state = reactive<{ + flashcard: Flashcard | null + policy: Policy | null + isLoading: boolean + isErrored: boolean + }>({ + flashcard: null, + policy: null, + isLoading: false, + isErrored: false, + }) + + async function fetch(): Promise { + const staticId = unref(id) + if (isNil(staticId)) { + throw new Error("id is required") + } + + state.isLoading = true + try { + const { flashcard, policy } = await flashcardsApi.get(staticId) + state.isErrored = false + state.flashcard = flashcard + state.policy = policy + return flashcard + } catch (error) { + console.error("Failed to fetch flashcard:", error) + state.isErrored = true + throw error + } finally { + state.isLoading = false + } + } + + async function save(): Promise { + const staticId = unref(id) + if (isNil(staticId)) { + throw new Error("id is required") + } + + if (isNil(state.flashcard)) { + throw new Error("No flashcard to save") + } + + state.isLoading = true + try { + const { flashcard } = await flashcardsApi.update(staticId, state.flashcard) + state.isErrored = false + state.flashcard = flashcard + return flashcard + } catch (error) { + console.error("Failed to save flashcard:", error) + state.isErrored = true + throw error + } finally { + state.isLoading = false + } + } + + watch( + () => unref(id), + async (newId) => { + if (isNil(newId)) return + + await fetch() + }, + { immediate: true } + ) + + return { + ...toRefs(state), + fetch, + refresh: fetch, + save, + } +} + +export default useFlashcard diff --git a/web/src/use/use-flashcards.ts b/web/src/use/use-flashcards.ts new file mode 100644 index 0000000..3d422d0 --- /dev/null +++ b/web/src/use/use-flashcards.ts @@ -0,0 +1,67 @@ +import { type Ref, reactive, toRefs, ref, unref, watch } from "vue" + +import flashcardsApi, { + type Flashcard, + type FlashcardWhereOptions, + type FlashcardFiltersOptions, + type FlashcardQueryOptions, +} from "@/api/flashcards-api" + +export { + type Flashcard, + type FlashcardWhereOptions, + type FlashcardFiltersOptions, + type FlashcardQueryOptions, +} + +export function useFlashcards( + queryOptions: Ref = ref({}), + { skipWatchIf = () => false }: { skipWatchIf?: () => boolean } = {} +) { + const state = reactive<{ + flashcards: Flashcard[] + totalCount: number + isLoading: boolean + isErrored: boolean + }>({ + flashcards: [], + totalCount: 0, + isLoading: false, + isErrored: false, + }) + + async function fetch(): Promise { + state.isLoading = true + try { + const { flashcards, totalCount } = await flashcardsApi.list(unref(queryOptions)) + state.isErrored = false + state.flashcards = flashcards + state.totalCount = totalCount + return flashcards + } catch (error) { + console.error("Failed to fetch flashcards:", error) + state.isErrored = true + throw error + } finally { + state.isLoading = false + } + } + + watch( + () => [skipWatchIf(), unref(queryOptions)], + async ([skip]) => { + if (skip) return + + await fetch() + }, + { deep: true, immediate: true } + ) + + return { + ...toRefs(state), + fetch, + refresh: fetch, + } +} + +export default useFlashcards