diff --git a/.trunk/.gitignore b/.trunk/.gitignore new file mode 100644 index 00000000..15966d08 --- /dev/null +++ b/.trunk/.gitignore @@ -0,0 +1,9 @@ +*out +*logs +*actions +*notifications +*tools +plugins +user_trunk.yaml +user.yaml +tmp diff --git a/.trunk/configs/.markdownlint.yaml b/.trunk/configs/.markdownlint.yaml new file mode 100644 index 00000000..b40ee9d7 --- /dev/null +++ b/.trunk/configs/.markdownlint.yaml @@ -0,0 +1,2 @@ +# Prettier friendly markdownlint config (all formatting rules disabled) +extends: markdownlint/style/prettier diff --git a/.trunk/configs/.shellcheckrc b/.trunk/configs/.shellcheckrc new file mode 100644 index 00000000..8c7b1ada --- /dev/null +++ b/.trunk/configs/.shellcheckrc @@ -0,0 +1,7 @@ +enable=all +source-path=SCRIPTDIR +disable=SC2154 + +# If you're having issues with shellcheck following source, disable the errors via: +# disable=SC1090 +# disable=SC1091 diff --git a/.trunk/configs/.yamllint.yaml b/.trunk/configs/.yamllint.yaml new file mode 100644 index 00000000..184e251f --- /dev/null +++ b/.trunk/configs/.yamllint.yaml @@ -0,0 +1,7 @@ +rules: + quoted-strings: + required: only-when-needed + extra-allowed: ["{|}"] + key-duplicates: {} + octal-values: + forbid-implicit-octal: true diff --git a/.trunk/configs/svgo.config.mjs b/.trunk/configs/svgo.config.mjs new file mode 100644 index 00000000..b86ef082 --- /dev/null +++ b/.trunk/configs/svgo.config.mjs @@ -0,0 +1,14 @@ +export default { + plugins: [ + { + name: 'preset-default', + params: { + overrides: { + removeViewBox: false, // https://github.com/svg/svgo/issues/1128 + sortAttrs: true, + removeOffCanvasPaths: true, + }, + }, + }, + ], +}; diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml new file mode 100644 index 00000000..e4dfa0a1 --- /dev/null +++ b/.trunk/trunk.yaml @@ -0,0 +1,39 @@ +# This file controls the behavior of Trunk: https://docs.trunk.io/cli +# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml +version: 0.1 +cli: + version: 1.22.11 +# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins) +plugins: + sources: + - id: trunk + ref: v1.7.0 + uri: https://github.com/trunk-io/plugins +# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes) +runtimes: + enabled: + - go@1.21.0 + - node@22.16.0 + - python@3.10.8 +# This is the section where you manage your linters. (https://docs.trunk.io/check/configuration) +lint: + enabled: + - actionlint@1.7.7 + - biome@1.9.4 + - checkov@3.2.437 + - git-diff-check + - markdownlint@0.45.0 + - oxipng@9.1.5 + - prettier@3.5.3 + - shellcheck@0.10.0 + - shfmt@3.6.0 + - svgo@3.3.2 + - trufflehog@3.88.35 + - yamllint@1.37.1 +actions: + disabled: + - trunk-announce + - trunk-check-pre-push + - trunk-fmt-pre-commit + enabled: + - trunk-upgrade-available diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..dcdee21a --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,190 @@ +# Architecture + +## Overview + +This service uses a modular, flat, feature-sliced, **Layered Architecture** inspired by the [NestJS philosophy](https://docs.nestjs.com/#philosophy). + +We separate the system into 3 main layers: + +1. **Controller** – Talks to the client +2. **Service** – Handles the business logic +3. **Repository** – Interacts with the database + +Each domain feature (e.g. `articles`, `profiles`, `tags`) is isolated into a top-level module folder, containing the above layers, and also: + +- **Mapper** - Transforms data between layers +- **Schema** - Defines database tables and relations + +```mermaid +graph TD + subgraph Controller Layer + C1[articles.controller.ts] + C2[comments.controller.ts] + C3[tags.controller.ts] + end + + subgraph Service Layer + S1[articles.service.ts] + S2[comments.service.ts] + S3[tags.service.ts] + end + + subgraph Repository Layer + R1[articles.repository.ts] + R2[comments.repository.ts] + R3[tags.repository.ts] + end + + subgraph Schema Layer + SC1[articles.schema.ts] + SC2[comments.schema.ts] + SC3[tags.schema.ts] + SC4[article-tags.schema.ts] + end + + subgraph Mapper Layer + M1[articles.mapper.ts] + M2[comments.mapper.ts] + M3[tags.mapper.ts] + end + + C1 --> S1 --> R1 --> SC1 + C2 --> S2 --> R2 --> SC2 + C3 --> S3 --> R3 --> SC3 + + S1 --> M1 + S2 --> M2 + S3 --> M3 + + SC1 --> SC4 + SC3 --> SC4 +``` + +## Layer Responsibilities + +### 1. Controller Layer (Client-facing) + +- Receives data from the client (DTO) +- Returns data to the client (DTO) +- Validates data types +- Calls the service layer +- Can shape requests and responses, without performing any business logic + +### 2. Service Layer (Business Logic) + +- Contains the business logic +- Can perform any kind of calculation or transformation as long as it's part of the business rules +- Validates logic rules (e.g., checking if a user can register) +- Handles errors and logging +- Calls the repository layer to get or save data +- Can receive controller-level DTOs, but must map or validate them before passing data to the repository + +### 3. Repository Layer (Database Access) + +- Talks to the database +- Only responsible for saving and retrieving data +- **No** assumptions about validation +- **No** business logic should go here +- Handles pagination, sorting, and other database-specific operations +- Returns raw database rows, not domain entities + +### Additional Layers + +#### Mapper (Data Transformation) + +- Transforms Row types from the database to domain entities or DTOs +- Performs camelCase vs. snake_case mapping if needed +- Convers Date to ISO strings for output, etc. + +#### Schema (Database Definitions) + +- Defines schemas using an ORM (e.g. `pgTable()` with Drizzle ORM and PostgreSQL) +- Optionally defines table relations (e.g. `relations()` with Drizzle ORM) + +## Type Conventions + +| Type | Layer | Purpose | +| ------------------------------------------------------ | ---------- | ---------------------------------------------- | +| `CreateThingDto`, `UpdateThingDto`, `ThingResponseDto` | Controller | Used to talk with the client | +| `IThing` | All | Common contract shared between layers | +| `Thing` | Repository | Defines how the data is stored in the database | + +## General Design Principles + +### 1. Flat, feature-sliced folder layout + +- Each feature (e.g. `articles/`, `comments/`) contains all its layers in one folder +- No deep nesting, no shared `controllers/`, `services/` folders + +### 2. One thing per file + +- DTOs are defined in `dto/` folder, one file per DTO +- Domain entities are interfaces in `interfaces/`, one per file +- Row types are colocated in `interfaces/` and inferred from Drizzle schema + +### 3. Relation-aware schema layer + +Table relations are colocated with their schema definition unless they grow large. + +### 4. Public API is shaped at the controller level + +DTOs match the RealWorld spec (e.g., `{ article: ... }`) but this wrapping is handled in the controller, not baked into types. + +## Type Design Principles + +1. **Interfaces vs Classes**: + + - Use interfaces (`IUser`) to define contracts between layers + - Use classes (`User`) for concrete implementations. The (database) entity is a concrete implementation of the interface. + - This separation allows for better testing and flexibility + +2. **Canonical Forms**: + + - Store canonical forms in the database (e.g., `birthdate`) + - The canonical form is represented in the entity (`User`) _and_ the interface (`IUser`) + - The DTO might use a different form, e.g. `CreateUserDto` might use `age` instead of `birthdate` + - Use mappers to convert between forms + +3. **System vs Domain Properties**: + - System properties (`id`, `createdAt`, `updatedAt`) are managed by the base entity + - Domain properties (e.g. `email`, `name`) are defined in the interface, enforced by the entity, and controlled by the DTOs + +## Examples + +### Example 1: Can register? + +```typescript +canRegister(user: Partial) { + if (user.email.endsWith('@banned.com')) { + throw new ForbiddenException('Email domain is not allowed'); + } + + if (!this.isOldEnough(user.birthdate)) { + throw new ForbiddenException('User must be at least 13 years old'); + } +} +``` + +This check lives in the service layer because: + +- It's business logic +- It could change based on product decisions +- It might be reused across different controllers (`signup`, `adminCreateUser`, etc.) +- If tomorrow we add GraphQL on top of our REST, this logic will remain the same + +### Example 2: Normalize email + +```typescript +normalizeEmail(email: string) { + return email.toLowerCase().trim(); +} +``` + +Also clearly service-level: it's a standardized rule, not controller-specific logic. + +## See also + +- More on **Project structure** - see [Project Structure](PROJECT_STRUCTURE.md) +- **Contributing** - see [Developer's Guide](CONTRIBUTING.md) +- **API Documentation** - see [RealWorld Backend Specifications](https://realworld-docs.netlify.app/specifications/backend/introduction/) +- **Drizzle ORM Documentation** - see [Drizzle ORM](https://orm.drizzle.team/) diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md new file mode 100644 index 00000000..22d8d8bd --- /dev/null +++ b/PROJECT_STRUCTURE.md @@ -0,0 +1,91 @@ +# Project Structure + +This document describes the file structure and organization of the codebase. The layout is flat and feature-sliced, with each domain feature (e.g., articles, comments) self-contained. + +We favor **separation of concerns** over minimalism, and follow a **one file per thing** rule. + +This layout is built to scale while staying clear, testable, and aligned with RealWorld API expectations. + +## Top-Level Overview + +``` +src/ +├── app.ts # Initializes and mounts the app +├── routes/ # Aggregates and mounts feature routers +├── db/ # Drizzle ORM config and database init +├── shared/ # Common utilities and helpers +├── articles/ # Full article feature module +├── comments/ # Full comment feature module +├── tags/ # Tag-related logic and schema +├── users/ # User logic (repo, profile dto) +``` + +## Feature Folder Layout + +Each feature (e.g. `articles/`, `comments/`) uses this layout: + +``` +feature/ +├── feature.controller.ts # REST handler logic +├── feature.service.ts # Business logic +├── feature.repository.ts # DB access logic +├── feature.mapper.ts # Converts DB to DTO +├── schema/ +│ └── feature.schema.ts # Drizzle schema + optional relations +├── dto/ +│ ├── create-feature.dto.ts # Input shape (TypeBox) +│ ├── update-feature.dto.ts # Input shape (if needed) +│ └── feature.dto.ts # Output DTO for response +├── interfaces/ +│ ├── feature.interface.ts # Domain model +│ └── feature-row.interface.ts# Drizzle-inferred DB shape +``` + +## Folder-Level Purpose + +### `/db/` + +* Drizzle config and init +* Exports the db instance +* Does **not** export db tables, these are found as schemas inside feature folders + +### `/shared/` + +Global utilities, middleware, and shared concerns. + +The following is a suggestion of what files might be found here: + +``` +shared/ +├── auth-middleware.ts # Extracts auth context +├── http-errors.ts # Shared error classes +├── slugify.ts # Utility for slug generation +``` + +## DTO Naming + +* `CreateThingDto` – used for `POST` requests +* `UpdateThingDto` – used for `PATCH`/`PUT` requests +* `ThingDto` – response structure +* Each DTO is defined via TypeBox and typed via `Static` +* DTO files live in `dto/` and share name with their schema + +### Interface Naming + +* `Thing` (or `ThingEntity`) – the core domain interface used in services +* `ThingRow` – the shape returned by Drizzle ORM (via InferSelectModel) +* Stored in `interfaces/` and always one interface per file + +### Schema Naming + +* `feature.schema.ts` – contains Drizzle `pgTable()` definitions +* May also define `relations()` in the same file unless very large +* If split, name the second file `feature-relations.schema.ts` + +## See also + +- More on **Architecture** - see [Architecture](ARCHITECTURE.md) +- **Contributing** - see [Developer's Guide](CONTRIBUTING.md) +- **API Documentation** - see [RealWorld Backend Specifications](https://realworld-docs.netlify.app/specifications/backend/introduction/) +- **Drizzle ORM Documentation** - see [Drizzle ORM](https://orm.drizzle.team/) + diff --git a/biome.json b/biome.json index 257cd6ea..01429636 100644 --- a/biome.json +++ b/biome.json @@ -5,6 +5,9 @@ "useIgnoreFile": true, "clientKind": "git" }, + "files": { + "ignore": [".trunk/**"] + }, "organizeImports": { "enabled": true }, diff --git a/bun.lockb b/bun.lockb index 0f2e2ada..143628d1 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/db/drop.ts b/db/drop.ts index 28efb87c..39e0090d 100644 --- a/db/drop.ts +++ b/db/drop.ts @@ -1,6 +1,10 @@ import { exit } from 'node:process'; +import { + articles, + comments, + favoriteArticles, +} from '@/articles/schema/favorite-articles.schema'; import { db } from '@/database.providers'; -import { articles, comments, favoriteArticles } from '@articles/articles.model'; import dbConfig from '@db/config'; import { articleTags, tags } from '@tags/tags.model'; import { userFollows, users } from '@users/users.model'; diff --git a/src/app.module.ts b/src/app.module.ts index a36cd6ed..e6c0cfe8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,4 @@ -import { articlesPlugin } from '@articles/articles.plugin'; +import { articlesController } from '@articles/articles.controller'; import { swagger } from '@elysiajs/swagger'; import { AuthenticationError, @@ -59,7 +59,7 @@ export const setupApp = () => { app .use(usersPlugin) .use(profilesPlugin) - .use(articlesPlugin) + .use(articlesController) .use(tagsPlugin), ); }; diff --git a/src/articles/articles.controller.ts b/src/articles/articles.controller.ts new file mode 100644 index 00000000..e9603e8f --- /dev/null +++ b/src/articles/articles.controller.ts @@ -0,0 +1,284 @@ +import { setupArticles } from '@articles/articles.module'; +import { CommentResponseDto, CreateCommentDto } from '@comments/dto'; +import { Elysia, t } from 'elysia'; +import { + ArticleFeedQueryDto, + ArticleResponseDto, + ArticlesResponseDto, + CreateArticleDto, + ListArticlesQueryDto, + UpdateArticleDto, +} from './dto'; +import { + toCommentResponse, + toCreateArticleInput, + toFeedResponse, + toResponse, +} from './mappers/articles.mapper'; + +export const articlesController = new Elysia().use(setupArticles).group( + '/articles', + { + detail: { + tags: ['Articles'], + }, + }, + (app) => + app + .get( + '/', + async ({ query, store, request }) => { + const currentUserId = + await store.authService.getOptionalUserIdFromHeader( + request.headers, + ); + + const { offset = 0, limit = 20, tag, author, favorited } = query; + + return toFeedResponse( + await store.articlesService.find( + { tag, author, favorited }, + { pagination: { offset, limit }, currentUserId }, + ), + ); + }, + { + query: ListArticlesQueryDto, + response: ArticlesResponseDto, + detail: { + summary: 'List Articles', + description: + 'Returns most recent articles globally by default, provide `tag`, `author` or `favorited` query parameter to filter results\n\nAuthentication optional, will return multiple articles, ordered by most recent first', + }, + }, + ) + .get( + '/feed', + async ({ query, store, request }) => { + const currentUserId = await store.authService.getUserIdFromHeader( + request.headers, + ); + + const { offset = 0, limit = 20 } = query; + + const { articles, articlesCount } = await store.articlesService.find( + {}, + { + personalization: { + followedAuthors: true, + }, + pagination: { offset, limit }, + currentUserId, + }, + ); + return toFeedResponse({ articles, articlesCount }); + }, + { + beforeHandle: app.store.authService.requireLogin, + query: ArticleFeedQueryDto, + response: ArticlesResponseDto, + detail: { + summary: 'Feed Articles', + description: + 'Can also take `limit` and `offeset` query parameters like List Articles\n\nAuthentication required, will return multiple articles created by followed users, ordered by most recent first.', + security: [ + { + tokenAuth: [], + }, + ], + }, + }, + ) + .get( + '/:slug', + async ({ params, store, request }) => { + const article = await store.articlesService.findBySlug( + params.slug, + await store.authService.getOptionalUserIdFromHeader( + request.headers, + ), + ); + return toResponse(article); + }, + { + response: ArticleResponseDto, + detail: { + summary: 'Get Article', + }, + }, + ) + .post( + '/', + async ({ body, request, store }) => { + const currentUserId = await store.authService.getUserIdFromHeader( + request.headers, + ); + const article = await store.articlesService.createArticle( + toCreateArticleInput(body), + currentUserId, + ); + return toResponse(article); + }, + { + beforeHandle: app.store.authService.requireLogin, + body: CreateArticleDto, + response: ArticleResponseDto, + detail: { + summary: 'Create Article', + security: [ + { + tokenAuth: [], + }, + ], + }, + }, + ) + .put( + '/:slug', + async ({ params, body, store, request }) => { + const article = await store.articlesService.updateArticle( + params.slug, + body.article, + await store.authService.getUserIdFromHeader(request.headers), + ); + return toResponse(article); + }, + { + beforeHandle: app.store.authService.requireLogin, + body: UpdateArticleDto, + response: ArticleResponseDto, + detail: { + summary: 'Update Article', + security: [ + { + tokenAuth: [], + }, + ], + }, + }, + ) + .delete( + '/:slug', + async ({ params, store, request }) => { + await store.articlesService.deleteArticle( + params.slug, + await store.authService.getUserIdFromHeader(request.headers), + ); + }, + { + beforeHandle: app.store.authService.requireLogin, + detail: { + summary: 'Delete Article', + security: [ + { + tokenAuth: [], + }, + ], + }, + }, + ) + .post( + '/:slug/comments', + async ({ body, params, store, request }) => { + const comment = await store.commentsService.createComment( + params.slug, + body.comment, + await store.authService.getUserIdFromHeader(request.headers), + ); + return toCommentResponse(comment); + }, + { + beforeHandle: app.store.authService.requireLogin, + body: CreateCommentDto, + response: CommentResponseDto, + detail: { + summary: 'Add Comments to an Article', + }, + }, + ) + .get( + '/:slug/comments', + async ({ params, store, request }) => { + const userId = await store.authService.getOptionalUserIdFromHeader( + request.headers, + ); + const comments = await store.commentsService.getComments( + params.slug, + userId === null ? undefined : userId, + ); + return { comments: comments.map(toCommentResponse) }; + }, + { + response: t.Object({ + comments: t.Array(CommentResponseDto), + }), + detail: { + summary: 'Get Comments from an Article', + }, + }, + ) + .delete( + '/:slug/comments/:id', + async ({ params, store, request }) => { + await store.commentsService.deleteComment( + params.slug, + Number.parseInt(params.id, 10), + await store.authService.getUserIdFromHeader(request.headers), + ); + }, + { + beforeHandle: app.store.authService.requireLogin, + params: t.Object({ + slug: t.String(), + id: t.String(), + }), + detail: { + summary: 'Delete Comment', + }, + }, + ) + .post( + '/:slug/favorite', + async ({ params, store, request }) => { + const article = await store.articlesService.favoriteArticle( + params.slug, + await store.authService.getUserIdFromHeader(request.headers), + ); + return toResponse(article); + }, + { + beforeHandle: app.store.authService.requireLogin, + response: ArticleResponseDto, + detail: { + summary: 'Favorite Article', + security: [ + { + tokenAuth: [], + }, + ], + }, + }, + ) + .delete( + '/:slug/favorite', + async ({ params, store, request }) => { + const article = await store.articlesService.unfavoriteArticle( + params.slug, + await store.authService.getUserIdFromHeader(request.headers), + ); + return toResponse(article); + }, + { + beforeHandle: app.store.authService.requireLogin, + response: ArticleResponseDto, + detail: { + summary: 'Unfavorite Article', + security: [ + { + tokenAuth: [], + }, + ], + }, + }, + ), +); diff --git a/src/articles/articles.model.ts b/src/articles/articles.model.ts deleted file mode 100644 index 63acef79..00000000 --- a/src/articles/articles.model.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { relations, sql } from 'drizzle-orm'; -import { - integer, - pgTable, - primaryKey, - serial, - text, - timestamp, -} from 'drizzle-orm/pg-core'; - -import { articleTags } from '@tags/tags.model'; -import { users } from '@users/users.model'; - -export const articles = pgTable('articles', { - id: serial('id').primaryKey(), - slug: text('slug').notNull().unique(), - title: text('title').notNull(), - description: text('description').notNull(), - body: text('body').notNull(), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - authorId: integer('author_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), -}); - -export const articleRelations = relations(articles, ({ one, many }) => ({ - author: one(users, { - fields: [articles.authorId], - references: [users.id], - relationName: 'author', - }), - favoritedBy: many(favoriteArticles, { - relationName: 'favoriteArticle', - }), - comments: many(comments, { - relationName: 'articleComments', - }), - tags: many(articleTags, { - relationName: 'articleTags', - }), -})); - -export const favoriteArticles = pgTable( - 'favorite_articles', - { - articleId: integer('article_id') - .references(() => articles.id, { onDelete: 'cascade' }) - .notNull(), - userId: integer('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - }, - (table) => [primaryKey({ columns: [table.articleId, table.userId] })], -); - -export const favoriteArticleRelations = relations( - favoriteArticles, - ({ one }) => ({ - article: one(articles, { - fields: [favoriteArticles.articleId], - references: [articles.id], - relationName: 'favoriteArticle', - }), - user: one(users, { - fields: [favoriteArticles.userId], - references: [users.id], - relationName: 'favoritedBy', - }), - }), -); - -export const comments = pgTable('comments', { - id: serial('id').primaryKey().notNull(), - body: text('body').notNull(), - articleId: integer('article_id') - .references(() => articles.id, { onDelete: 'cascade' }) - .notNull(), - authorId: integer('author_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - createdAt: timestamp('created_at').default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedAt: timestamp('updated_at').default(sql`CURRENT_TIMESTAMP`).notNull(), -}); - -export const commentRelations = relations(comments, ({ one }) => ({ - article: one(articles, { - fields: [comments.articleId], - references: [articles.id], - relationName: 'articleComments', - }), - author: one(users, { - fields: [comments.authorId], - references: [users.id], - relationName: 'commentAuthor', - }), -})); diff --git a/src/articles/articles.module.ts b/src/articles/articles.module.ts index 3629797c..e66c01f0 100644 --- a/src/articles/articles.module.ts +++ b/src/articles/articles.module.ts @@ -1,5 +1,5 @@ -import { CommentsRepository } from '@/articles/comments/comments.repository'; -import { CommentsService } from '@/articles/comments/comments.service'; +import { CommentsRepository } from '@/comments/comments.repository'; +import { CommentsService } from '@/comments/comments.service'; import { db } from '@/database.providers'; import { ArticlesRepository } from '@articles/articles.repository'; import { ArticlesService } from '@articles/articles.service'; diff --git a/src/articles/articles.plugin.ts b/src/articles/articles.plugin.ts deleted file mode 100644 index d9f1cd0b..00000000 --- a/src/articles/articles.plugin.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { setupArticles } from '@articles/articles.module'; -import { - ArticleFeedQuerySchema, - DeleteArticleResponse, - InsertArticleSchema, - ListArticlesQuerySchema, - ReturnedArticleListSchema, - ReturnedArticleResponseSchema, - UpdateArticleSchema, -} from '@articles/articles.schema'; -import { Elysia, t } from 'elysia'; -import { - AddCommentSchema, - DeleteCommentResponse, - ReturnedCommentResponse, - ReturnedCommentsResponse, -} from './comments/comments.schema'; - -export const articlesPlugin = new Elysia().use(setupArticles).group( - '/articles', - { - detail: { - tags: ['Articles'], - }, - }, - (app) => - app - .get( - '/', - async ({ query, store, request }) => - store.articlesService.find({ - ...query, - currentUserId: await store.authService.getOptionalUserIdFromHeader( - request.headers, - ), - }), - { - query: ListArticlesQuerySchema, - response: ReturnedArticleListSchema, - detail: { - summary: 'List Articles', - }, - }, - ) - .post( - '/', - async ({ body, request, store }) => - store.articlesService.createArticle( - body.article, - await store.authService.getUserIdFromHeader(request.headers), - ), - { - beforeHandle: app.store.authService.requireLogin, - body: InsertArticleSchema, - response: ReturnedArticleResponseSchema, - detail: { - summary: 'Create Article', - security: [ - { - tokenAuth: [], - }, - ], - }, - }, - ) - .get( - '/feed', - async ({ query, store, request }) => - store.articlesService.find({ - ...query, - currentUserId: await store.authService.getUserIdFromHeader( - request.headers, - ), - followedAuthors: true, - }), - { - beforeHandle: app.store.authService.requireLogin, - query: ArticleFeedQuerySchema, - response: ReturnedArticleListSchema, - detail: { - summary: 'Artifle Feed', - security: [ - { - tokenAuth: [], - }, - ], - }, - }, - ) - .get( - '/:slug', - async ({ params, store, request }) => - store.articlesService.findBySlug( - params.slug, - await store.authService.getOptionalUserIdFromHeader( - request.headers, - ), - ), - { - response: ReturnedArticleResponseSchema, - detail: { - summary: 'Get Article', - }, - }, - ) - .put( - '/:slug', - async ({ params, body, store, request }) => - store.articlesService.updateArticle( - params.slug, - body.article, - await store.authService.getUserIdFromHeader(request.headers), - ), - { - beforeHandle: app.store.authService.requireLogin, - body: UpdateArticleSchema, - response: ReturnedArticleResponseSchema, - detail: { - summary: 'Update Article', - security: [ - { - tokenAuth: [], - }, - ], - }, - }, - ) - .delete( - '/:slug', - async ({ params, store, request }) => - store.articlesService.deleteArticle( - params.slug, - await store.authService.getUserIdFromHeader(request.headers), - ), - { - beforeHandle: app.store.authService.requireLogin, - response: DeleteArticleResponse, - detail: { - summary: 'Delete Article', - security: [ - { - tokenAuth: [], - }, - ], - }, - }, - ) - .post( - '/:slug/comments', - async ({ body, params, store, request }) => { - const comment = await store.commentsService.createComment( - params.slug, - body.comment, - await store.authService.getUserIdFromHeader(request.headers), - ); - return { comment }; - }, - { - beforeHandle: app.store.authService.requireLogin, - params: t.Object({ - slug: t.String(), - }), - body: AddCommentSchema, - response: ReturnedCommentResponse, - detail: { - summary: 'Add Comment to Article', - }, - }, - ) - .get( - '/:slug/comments', - async ({ params, store, request }) => { - const userId = await store.authService.getOptionalUserIdFromHeader( - request.headers, - ); - return { - comments: await store.commentsService.getComments( - params.slug, - userId === null ? undefined : userId, - ), - }; - }, - { - params: t.Object({ - slug: t.String(), - }), - response: ReturnedCommentsResponse, - detail: { - summary: 'Get Comments from Article', - }, - }, - ) - .delete( - '/:slug/comments/:id', - async ({ params, store, request }) => { - await store.commentsService.deleteComment( - params.slug, - Number.parseInt(params.id, 10), - await store.authService.getUserIdFromHeader(request.headers), - ); - return {}; - }, - { - beforeHandle: app.store.authService.requireLogin, - params: t.Object({ - slug: t.String(), - id: t.String(), - }), - response: DeleteCommentResponse, - detail: { - summary: 'Delete Comment', - }, - }, - ) - .post( - '/:slug/favorite', - async ({ params, store, request }) => - store.articlesService.favoriteArticle( - params.slug, - await store.authService.getUserIdFromHeader(request.headers), - ), - { - beforeHandle: app.store.authService.requireLogin, - response: ReturnedArticleResponseSchema, - detail: { - summary: 'Favorite Article', - security: [ - { - tokenAuth: [], - }, - ], - }, - }, - ) - .delete( - '/:slug/favorite', - async ({ params, store, request }) => - store.articlesService.unfavoriteArticle( - params.slug, - await store.authService.getUserIdFromHeader(request.headers), - ), - { - beforeHandle: app.store.authService.requireLogin, - response: ReturnedArticleResponseSchema, - detail: { - summary: 'Unfavorite Article', - security: [ - { - tokenAuth: [], - }, - ], - }, - }, - ), -); diff --git a/src/articles/articles.repository.ts b/src/articles/articles.repository.ts index d2a3d6da..18162d0e 100644 --- a/src/articles/articles.repository.ts +++ b/src/articles/articles.repository.ts @@ -1,181 +1,162 @@ -import type { - ArticleToCreate, - ArticleToUpdate, -} from '@/articles/articles.schema'; import type { Database } from '@/database.providers'; -import { userFollows, users } from '@/users/users.model'; -import { articles, favoriteArticles } from '@articles/articles.model'; import { articleTags } from '@tags/tags.model'; +import { userFollows, users } from '@users/users.model'; import { and, count, desc, eq, inArray, sql } from 'drizzle-orm'; +import { articles, favoriteArticles } from './articles.schema'; +import type { + ArticleFeedRow, + ArticleRow, + NewArticleRow, + UpdateArticleRow, +} from './interfaces'; + +type FindFilters = { + tag?: string; + author?: string; + favorited?: string; +}; + +type FindOptions = { + offset: number; + limit: number; + currentUserId?: number; + followedAuthorIds?: number[]; +}; export class ArticlesRepository { constructor(private readonly db: Database) {} - async find({ - currentUserId, - offset, - limit, - tag, - author, - favorited, - followedAuthors, - }: { - currentUserId: number | null; - offset: number; - limit: number; - tag?: string; - author?: string; - favorited?: string; - followedAuthors?: boolean; - }) { - const authorFilters = []; - if (author) { - authorFilters.push(eq(users.username, author)); - } - if (followedAuthors && currentUserId) { - authorFilters.push( - inArray( - users.id, - this.db - .select({ followedAuthors: userFollows.followedId }) - .from(userFollows) - .where(eq(userFollows.followerId, currentUserId)), - ), - ); + private async findExistingSlug(slug: string): Promise { + const result = await this.db + .select({ slug: articles.slug }) + .from(articles) + .where(eq(articles.slug, slug)) + .limit(1); + return result[0]?.slug ?? null; + } + + private async generateUniqueSlug( + baseSlug: string, + authorId: number, + ): Promise { + // Get the author's username + const author = await this.db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, authorId)) + .limit(1); + + if (!author[0]) { + throw new Error('Author not found'); } - const authorsWithFollowersCTE = this.db.$with('authorsWithFollowers').as( - this.db - .select({ - authorId: users.id, - authorUsername: users.username, - authorBio: users.bio, - authorImage: users.image, - authorFollowing: - sql`coalesce(${currentUserId} = any(array_agg(user_follows.follower_id)), false)`.as( - 'authorFollowing', - ), - }) - .from(users) - .leftJoin(userFollows, eq(users.id, userFollows.followedId)) - .where(and(...authorFilters)) - .groupBy(users.id), - ); + const username = author[0].username; + const slug = `${baseSlug}-by-${username}`; - const articleFilters = []; - if (favorited) { - articleFilters.push(eq(users.username, favorited)); + // If this exact slug exists, append a timestamp to make it unique + if (await this.findExistingSlug(slug)) { + const timestamp = Date.now(); + return `${slug}-${timestamp}`; } - const articlesWithLikesCTE = this.db.$with('articlesWithLikes').as( - this.db - .select({ - articleId: articles.id, - favorited: - sql`coalesce(${currentUserId} = any(array_agg(favorite_articles.user_id)), false)`.as( - 'favorited', - ), - favoriteCount: sql`count(*)::integer`.as('favoriteCount'), - }) - .from(articles) - .leftJoin(favoriteArticles, eq(favoriteArticles.articleId, articles.id)) - .leftJoin(users, eq(users.id, favoriteArticles.userId)) - .where(and(...articleFilters)) - .groupBy(articles.id), - ); + return slug; + } - const articlesWithTagsCTE = this.db.$with('articlesWithTags').as( - this.db - .select({ - articleId: articles.id, - tags: sql` - array_agg(article_tags.tag_name order by article_tags.tag_name ASC) - filter (where article_tags.tag_name is not null) - `.as('tags'), - }) - .from(articles) - .innerJoin(users, eq(users.id, articles.authorId)) - .leftJoin(articleTags, eq(articleTags.articleId, articles.id)) - .where(and(...authorFilters, ...articleFilters)) - .groupBy(articles.id) - // Having can't be used with aliases, the calculation must be repeated - .having( - tag ? sql`${tag} = any(array_agg(article_tags.tag_name))` : sql`true`, - ), - ); + async find( + { author, tag, favorited }: FindFilters, + { offset, limit, currentUserId, followedAuthorIds }: FindOptions, + ): Promise<{ articles: ArticleFeedRow[]; articlesCount: number }> { + const authorFilters = []; + if (author) { + authorFilters.push(eq(users.username, author)); + } + + if (followedAuthorIds?.length) { + authorFilters.push(inArray(users.id, followedAuthorIds)); + } - const resultsQuery = this.db - .with(authorsWithFollowersCTE, articlesWithLikesCTE, articlesWithTagsCTE) + const baseQuery = this.db .select({ slug: articles.slug, title: articles.title, description: articles.description, - // Case-when is not natively suppoerted yet - // https://github.com/drizzle-team/drizzle-orm/issues/1065 tagList: sql` - case - when ${articlesWithTagsCTE.tags} is not null then ${articlesWithTagsCTE.tags} - else '{}'::text[] - end - `.as('tagList'), + coalesce( + array_agg(article_tags.tag_name order by article_tags.tag_name ASC) + filter (where article_tags.tag_name is not null), + '{}'::text[] + ) + `.as('tagList'), createdAt: articles.createdAt, updatedAt: articles.updatedAt, - favorited: articlesWithLikesCTE.favorited, - favoritesCount: articlesWithLikesCTE.favoriteCount, + favorited: sql` + coalesce( + exists ( + select 1 from ${favoriteArticles} + where ${favoriteArticles.articleId} = ${articles.id} + and ${favoriteArticles.userId} = ${currentUserId ?? sql`null`} + ), + false + ) + `.as('favorited'), + favoritesCount: + sql`count(distinct favorite_articles.user_id)::integer`.as( + 'favoriteCount', + ), author: { - username: authorsWithFollowersCTE.authorUsername, - bio: authorsWithFollowersCTE.authorBio, - image: authorsWithFollowersCTE.authorImage, - following: authorsWithFollowersCTE.authorFollowing, + username: users.username, + bio: users.bio, + image: users.image, + following: sql` + coalesce( + exists ( + select 1 from ${userFollows} + where ${userFollows.followedId} = ${users.id} + and ${userFollows.followerId} = ${currentUserId ?? sql`null`} + ), + false + ) + `.as('following'), }, }) .from(articles) - .innerJoin( - articlesWithLikesCTE, - eq(articlesWithLikesCTE.articleId, articles.id), - ) - .innerJoin( - authorsWithFollowersCTE, - eq(authorsWithFollowersCTE.authorId, articles.authorId), - ) - .innerJoin( - articlesWithTagsCTE, - eq(articlesWithTagsCTE.articleId, articles.id), - ) - .orderBy(desc(articles.createdAt)) - .as('results'); + .innerJoin(users, eq(users.id, articles.authorId)) + .leftJoin(articleTags, eq(articleTags.articleId, articles.id)) + .leftJoin(favoriteArticles, eq(favoriteArticles.articleId, articles.id)) + .where(and(...authorFilters)) + .groupBy(articles.id, users.id) + .orderBy(desc(articles.createdAt)); - const limitedResults = await this.db - .select() - .from(resultsQuery) - .limit(limit) - .offset(offset); + const limitedResults = await baseQuery.limit(limit).offset(offset); const resultsCount = await this.db .select({ count: count() }) - .from(resultsQuery); + .from(baseQuery.as('base')); - return { articles: limitedResults, articlesCount: resultsCount[0].count }; + return { + articles: limitedResults, + articlesCount: resultsCount[0].count, + }; } - async findBySlug(slug: string) { - const result = await this.db.query.articles.findFirst({ - where: eq(articles.slug, slug), - with: { - author: { - with: { - followers: true, + async findBySlug(slug: string): Promise { + return ( + (await this.db.query.articles.findFirst({ + where: eq(articles.slug, slug), + with: { + author: { + with: { + followers: true, + }, }, + favoritedBy: true, + tags: true, }, - favoritedBy: true, - tags: true, - }, - }); - if (!result) return null; - return result; + })) ?? null + ); } - async findById(id: number) { + async findById(id: number): Promise { const result = await this.db.query.articles.findFirst({ where: eq(articles.id, id), with: { @@ -188,37 +169,44 @@ export class ArticlesRepository { tags: true, }, }); - if (!result) return null; - return result; + return result ?? null; } - async createArticle(article: ArticleToCreate) { + async createArticle(article: NewArticleRow): Promise { const results = await this.db.insert(articles).values(article).returning(); + const newArticle = results[0]; - return this.findById(newArticle.id); + const result = await this.findById(newArticle.id); + if (!result) { + throw new Error(`Article with id ${newArticle.id} not found`); + } + return result; } async updateArticle( - articleId: number, - article: ArticleToUpdate, + slug: string, + article: UpdateArticleRow, currentUserId: number, - ) { - const filteredArticle = Object.fromEntries( - Object.entries(article).filter(([_, value]) => value !== undefined), - ); - await this.db + ): Promise { + const results = await this.db .update(articles) .set({ - ...filteredArticle, + ...article, updatedAt: new Date(), }) - .where( - and(eq(articles.id, articleId), eq(articles.authorId, currentUserId)), - ); + .where(and(eq(articles.slug, slug), eq(articles.authorId, currentUserId))) + .returning(); + + const updatedArticle = results[0]; + const result = await this.findById(updatedArticle.id); + if (!result) { + throw new Error(`Article with id ${updatedArticle.id} not found`); + } + return result; } - async deleteArticle(slug: string, currentUserId: number) { - return await this.db + async deleteArticle(slug: string, currentUserId: number): Promise { + await this.db .delete(articles) .where( and(eq(articles.slug, slug), eq(articles.authorId, currentUserId)), diff --git a/src/articles/articles.schema.ts b/src/articles/articles.schema.ts index 75c8f093..50fcbf34 100644 --- a/src/articles/articles.schema.ts +++ b/src/articles/articles.schema.ts @@ -1,106 +1,73 @@ -import { MAX_PAGINATION_LIMIT } from '@/constants'; -import type { Profile } from '@profiles/profiles.schema'; -import { type Static, Type } from '@sinclair/typebox'; -import type { ArticleTag } from '@tags/tags.schema'; -import { createInsertSchema, createSelectSchema } from 'drizzle-typebox'; -import { articles, type favoriteArticles } from './articles.model'; +import { users } from '@/users/users.model'; +import { articleTags } from '@tags/tags.model'; +import { relations } from 'drizzle-orm'; +import { + integer, + primaryKey, + serial, + pgTable as table, + text, + timestamp, +} from 'drizzle-orm/pg-core'; +import { comments } from '../comments/schema'; -export const insertArticleSchemaRaw = createInsertSchema(articles); -export const selectArticleSchemaRaw = createSelectSchema(articles); - -export const InsertArticleSchema = Type.Object({ - article: Type.Composite([ - Type.Pick(insertArticleSchemaRaw, ['title', 'description', 'body']), - Type.Object({ tagList: Type.Optional(Type.Array(Type.String())) }), - ]), -}); - -export type ArticleToCreateData = Static['article']; -export type ArticleToCreate = Omit & { - authorId: number; - slug: string; -}; - -export const UpdateArticleSchema = Type.Object({ - article: Type.Composite([ - Type.Partial( - Type.Pick(insertArticleSchemaRaw, ['title', 'description', 'body']), - ), - Type.Object({ - tagList: Type.Optional(Type.Array(Type.String())), - }), - ]), +export const articles = table('articles', { + id: serial('id').primaryKey(), + slug: text('slug').notNull().unique(), + title: text('title').notNull(), + description: text('description').notNull(), + body: text('body').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + authorId: integer('author_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), }); -export type ArticleToUpdateRequest = Static< - typeof UpdateArticleSchema ->['article']; -export type ArticleToUpdate = Omit & { - slug: string; -}; +export const favoriteArticles = table( + 'favorite_articles', + { + articleId: integer('article_id') + .references(() => articles.id, { onDelete: 'cascade' }) + .notNull(), + userId: integer('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (table) => [primaryKey({ columns: [table.articleId, table.userId] })], +); -export const ReturnedArticleSchema = Type.Composite([ - Type.Omit(selectArticleSchemaRaw, ['id', 'authorId']), - Type.Object({ - author: Type.Object({ - username: Type.String(), - bio: Type.String(), - image: Type.String(), - following: Type.Boolean(), - }), - favorited: Type.Boolean(), - favoritesCount: Type.Number(), +export const articleRelations = relations(articles, ({ one, many }) => ({ + author: one(users, { + fields: [articles.authorId], + references: [users.id], + relationName: 'author', }), - Type.Object({ tagList: Type.Array(Type.String()) }), -]); - -export const ReturnedArticleResponseSchema = Type.Object({ - article: ReturnedArticleSchema, -}); - -export type ReturnedArticle = Static; -export type ReturnedArticleResponse = Static< - typeof ReturnedArticleResponseSchema ->; - -export type ArticleInDb = Omit< - typeof articles.$inferSelect, - 'id' | 'authorId' -> & { - author: Profile; - favoritedBy: ArticleFavoritedBy[]; - tags: ArticleTag[]; -}; - -export type ArticleFavoritedBy = typeof favoriteArticles.$inferSelect; + favoritedBy: many(favoriteArticles, { + relationName: 'favoriteArticle', + }), + comments: many(comments, { + relationName: 'articleComments', + }), + tags: many(articleTags, { + relationName: 'articleTags', + }), +})); -export const ArticleFeedQuerySchema = Type.Object({ - limit: Type.Optional( - Type.Number({ - minimum: 1, - maximum: MAX_PAGINATION_LIMIT, - default: 20, +export const favoriteArticleRelations = relations( + favoriteArticles, + ({ one }) => ({ + article: one(articles, { + fields: [favoriteArticles.articleId], + references: [articles.id], + relationName: 'favoriteArticle', + }), + user: one(users, { + fields: [favoriteArticles.userId], + references: [users.id], + relationName: 'favoritedBy', }), - ), - offset: Type.Optional(Type.Number({ minimum: 0, default: 0 })), -}); -export const ListArticlesQuerySchema = Type.Composite([ - ArticleFeedQuerySchema, - Type.Object({ - tag: Type.Optional(Type.String()), - author: Type.Optional(Type.String()), - favorited: Type.Optional(Type.String()), }), -]); - -export const ReturnedArticleListSchema = Type.Object({ - articles: Type.Array(Type.Omit(ReturnedArticleSchema, ['body'])), - articlesCount: Type.Number(), -}); - -export type ReturnedArticleList = Static; - -export const DeleteArticleResponse = Type.Object({ - message: Type.String(), - slug: Type.String(), -}); +); diff --git a/src/articles/articles.service.ts b/src/articles/articles.service.ts index 225d3e59..e0752bd9 100644 --- a/src/articles/articles.service.ts +++ b/src/articles/articles.service.ts @@ -1,17 +1,44 @@ -import { AuthorizationError, BadRequestError } from '@/errors'; +import { AuthorizationError, BadRequestError, ConflictError } from '@/errors'; +import type { ProfilesService } from '@/profiles/profiles.service'; import { slugify } from '@/utils/slugify'; import type { ArticlesRepository } from '@articles/articles.repository'; -import type { - ArticleInDb, - ArticleToCreate, - ArticleToCreateData, - ArticleToUpdateRequest, - ReturnedArticleList, - ReturnedArticleResponse, -} from '@articles/articles.schema'; -import type { ProfilesService } from '@profiles/profiles.service'; import type { TagsService } from '@tags/tags.service'; import { NotFoundError } from 'elysia'; +import type { + CreateArticleInput, + IArticle, + IArticleFeed, + UpdateArticleInput, +} from './interfaces'; +import { + toDomain, + toFeedDomain, + toNewArticleRow, +} from './mappers/articles.mapper'; + +type FindFilters = { + tag?: string; + author?: string; + favorited?: string; +}; + +type PersonalizationOptions = { + /** + * Whether to include articles from followed authors. If not specified, all articles will be returned + */ + followedAuthors?: boolean; +}; + +type PaginationOptions = { + offset?: number; + limit?: number; +}; + +type FindOptions = { + pagination?: PaginationOptions; + currentUserId?: number; + personalization?: PersonalizationOptions; +}; export class ArticlesService { constructor( @@ -20,120 +47,127 @@ export class ArticlesService { private readonly tagsService: TagsService, ) {} - async find(query: { - currentUserId: number | null; - offset?: number; - limit?: number; - tag?: string; - author?: string; - favorited?: string; - followedAuthors?: boolean; - }): Promise { - const limit = query.limit || 20; - const offset = query.offset || 0; - return await this.repository.find({ ...query, limit, offset }); + async find( + filters: FindFilters, + options: FindOptions = {}, + ): Promise<{ articles: IArticleFeed[]; articlesCount: number }> { + const { pagination, currentUserId, personalization } = options; + const { offset = 0, limit = 20 } = pagination ?? {}; + const { followedAuthors } = personalization ?? {}; + + const followedAuthorIds = + followedAuthors && currentUserId + ? await this.profilesService.findFollowedUserIds(currentUserId) + : undefined; + + const { articles, articlesCount } = await this.repository.find(filters, { + offset, + limit, + currentUserId, + followedAuthorIds, + }); + + return { + articles: articles.map((article) => toFeedDomain(article)), + articlesCount, + }; } - async findBySlug(slug: string, currentUserId: number | null = null) { + async findBySlug( + slug: string, + currentUserId: number | null = null, + ): Promise { const article = await this.repository.findBySlug(slug); if (!article) { throw new NotFoundError('Article not found'); } - return await this.generateArticleResponse(article, currentUserId); + return toDomain(article, { currentUserId: currentUserId ?? undefined }); } - async createArticle(article: ArticleToCreateData, currentUserId: number) { - const articleToCreate: ArticleToCreate = { - ...article, - authorId: currentUserId, - slug: slugify(article.title), - }; - // TODO: Add transaction to ensure both or none of the operations are done - const createdArticle = await this.repository.createArticle(articleToCreate); + async createArticle( + article: CreateArticleInput, + currentUserId: number, + ): Promise { + const newArticle = toNewArticleRow(article, currentUserId); + + // Check if any article exists with this title + const existingArticle = await this.repository.findBySlug(newArticle.slug); + if (existingArticle) { + throw new ConflictError('An article with this title already exists'); + } + + const createdArticle = await this.repository.createArticle(newArticle); + if (!createdArticle) { throw new BadRequestError('Article was not created'); } - if (article.tagList) { - await this.tagsService.upsertArticleTags( + + let { tagList } = article; + if (article.tagList.length) { + const upsertArticleTagsResult = await this.tagsService.upsertArticleTags( createdArticle.id, article.tagList, ); + if (upsertArticleTagsResult) { + tagList = upsertArticleTagsResult.map((t) => t.tagName); + } } - return await this.generateArticleResponse(createdArticle, currentUserId); + + return toDomain(createdArticle, { + currentUserId: currentUserId ?? undefined, + tagList, + }); } async updateArticle( slug: string, - article: ArticleToUpdateRequest, + article: UpdateArticleInput, currentUserId: number, - ) { - // TODO: Add transaction to ensure both or none of the operations are done - const { tagList, ...articleData } = article; + ): Promise { const existingArticle = await this.repository.findBySlug(slug); if (!existingArticle) { throw new NotFoundError('Article not found'); } - if (existingArticle.authorId !== currentUserId) { + if (existingArticle.author.id !== currentUserId) { throw new AuthorizationError('Only the author can update the article'); } - const newSlug = articleData.title - ? slugify(articleData.title) - : existingArticle.slug; - await this.repository.updateArticle( - existingArticle.id, - { ...articleData, slug: newSlug }, + const updatedArticle = await this.repository.updateArticle( + slug, + { + ...article, + slug: article.title ? slugify(article.title) : undefined, + }, currentUserId, ); + let { tagList } = article; if (tagList) { - await this.tagsService.upsertArticleTags(existingArticle.id, tagList); + const upsertArticleTagsResult = await this.tagsService.upsertArticleTags( + updatedArticle.id, + tagList, + ); + if (upsertArticleTagsResult) { + tagList = upsertArticleTagsResult.map((t) => t.tagName); + } } - return this.findBySlug(newSlug, currentUserId); + return toDomain(updatedArticle, { + currentUserId, + tagList, + }); } - async deleteArticle(slug: string, currentUserId: number) { + async deleteArticle(slug: string, currentUserId: number): Promise { const article = await this.repository.findBySlug(slug); if (!article) { throw new NotFoundError('Article not found'); } - if (article.authorId !== currentUserId) { + if (article.author.id !== currentUserId) { throw new AuthorizationError('Only the author can delete the article'); } - // articleTags will be deleted as well due to the cascade rule await this.repository.deleteArticle(slug, currentUserId); - return { - message: 'Article deleted', - slug: article.slug, - }; - } - - async generateArticleResponse( - article: ArticleInDb, - currentUserId: number | null, - ): Promise { - const authorProfile = await this.profilesService.generateProfileResponse( - article.author, - currentUserId, - ); - return { - article: { - slug: article.slug, - title: article.title, - description: article.description, - body: article.body, - tagList: article.tags.map((tag) => tag.tagName), - createdAt: article.createdAt, - updatedAt: article.updatedAt, - author: authorProfile.profile, - favorited: !!article.favoritedBy.find( - (user) => user.userId === currentUserId, - ), - favoritesCount: article.favoritedBy.length, - }, - }; } async favoriteArticle(slug: string, currentUserId: number) { @@ -141,14 +175,17 @@ export class ArticlesService { if (!article) { throw new NotFoundError('Article not found'); } - return await this.generateArticleResponse(article, currentUserId); + return toDomain(article, { currentUserId }); } async unfavoriteArticle(slug: string, currentUserId: number) { - const article = await this.repository.unfavoriteArticle(slug, currentUserId); + const article = await this.repository.unfavoriteArticle( + slug, + currentUserId, + ); if (!article) { throw new NotFoundError('Article not found'); } - return await this.generateArticleResponse(article, currentUserId); + return toDomain(article, { currentUserId }); } } diff --git a/src/articles/comments/comments.schema.ts b/src/articles/comments/comments.schema.ts deleted file mode 100644 index 5daacc1e..00000000 --- a/src/articles/comments/comments.schema.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { selectUserSchemaRaw } from '@/users/users.schema'; -import { comments } from '@articles/articles.model'; -import { type Static, Type } from '@sinclair/typebox'; -import { createInsertSchema, createSelectSchema } from 'drizzle-typebox'; - -export const insertCommentSchemaRaw = createInsertSchema(comments); -export const selectCommentSchemaRaw = createSelectSchema(comments); - -export const AddCommentSchema = Type.Object({ - comment: Type.Object({ - body: Type.String(), - }), -}); - -export type CommentToCreate = Static['comment'] & { - authorId: number; - articleId: number; -}; - -export const ReturnedCommentSchema = Type.Composite([ - Type.Omit(selectCommentSchemaRaw, ['articleId', 'authorId']), - Type.Object({ - author: Type.Composite([ - Type.Omit(selectUserSchemaRaw, [ - 'id', - 'email', - 'password', - 'createdAt', - 'updatedAt', - ]), - Type.Object({ - following: Type.Boolean(), - }), - ]), - }), -]); - -export type ReturnedComment = Static; - -export const ReturnedCommentResponse = Type.Object({ - comment: ReturnedCommentSchema, -}); - -export const ReturnedCommentsResponse = Type.Object({ - comments: Type.Array(ReturnedCommentSchema), -}); - -export const DeleteCommentResponse = Type.Object({}); diff --git a/src/articles/dto/article-feed-query.dto.ts b/src/articles/dto/article-feed-query.dto.ts new file mode 100644 index 00000000..93b27786 --- /dev/null +++ b/src/articles/dto/article-feed-query.dto.ts @@ -0,0 +1,14 @@ +import { MAX_PAGINATION_LIMIT } from '@constants'; +import { type Static, Type } from '@sinclair/typebox'; + +export const ArticleFeedQueryDto = Type.Object({ + limit: Type.Optional( + Type.Number({ + minimum: 1, + maximum: MAX_PAGINATION_LIMIT, + default: 20, + }), + ), + offset: Type.Optional(Type.Number({ minimum: 0, default: 0 })), +}); +export type ArticleFeedQueryDto = Static; diff --git a/src/articles/dto/article-response.dto.ts b/src/articles/dto/article-response.dto.ts new file mode 100644 index 00000000..75f6582e --- /dev/null +++ b/src/articles/dto/article-response.dto.ts @@ -0,0 +1,22 @@ +import { type Static, Type } from '@sinclair/typebox'; + +export const ArticleResponseDto = Type.Object({ + article: Type.Object({ + slug: Type.String(), + title: Type.String(), + description: Type.String(), + body: Type.String(), + tagList: Type.Array(Type.String()), + createdAt: Type.String(), + updatedAt: Type.String(), + favorited: Type.Boolean(), + favoritesCount: Type.Number(), + author: Type.Object({ + username: Type.String(), + bio: Type.String(), + image: Type.String(), + following: Type.Boolean(), + }), + }), +}); +export type ArticleResponseDto = Static; diff --git a/src/articles/dto/articles-response.dto.ts b/src/articles/dto/articles-response.dto.ts new file mode 100644 index 00000000..11f92b93 --- /dev/null +++ b/src/articles/dto/articles-response.dto.ts @@ -0,0 +1,10 @@ +import { type Static, Type } from '@sinclair/typebox'; +import { ArticleResponseDto } from './article-response.dto'; + +export const ArticlesResponseDto = Type.Object({ + articles: Type.Array( + Type.Omit(ArticleResponseDto.properties.article, ['body']), + ), + articlesCount: Type.Number(), +}); +export type ArticlesResponseDto = Static; diff --git a/src/articles/dto/create-article.dto.ts b/src/articles/dto/create-article.dto.ts new file mode 100644 index 00000000..00edc917 --- /dev/null +++ b/src/articles/dto/create-article.dto.ts @@ -0,0 +1,11 @@ +import { type Static, Type } from '@sinclair/typebox'; + +export const CreateArticleDto = Type.Object({ + article: Type.Object({ + title: Type.String({ minLength: 1 }), + description: Type.String({ minLength: 1 }), + body: Type.String({ minLength: 1 }), + tagList: Type.Optional(Type.Array(Type.String())), + }), +}); +export type CreateArticleDto = Static; diff --git a/src/articles/dto/index.ts b/src/articles/dto/index.ts new file mode 100644 index 00000000..f579e552 --- /dev/null +++ b/src/articles/dto/index.ts @@ -0,0 +1,6 @@ +export * from './article-response.dto'; +export * from './articles-response.dto'; +export * from './create-article.dto'; +export * from './list-articles-query.dto'; +export * from './update-article.dto'; +export * from './article-feed-query.dto'; diff --git a/src/articles/dto/list-articles-query.dto.ts b/src/articles/dto/list-articles-query.dto.ts new file mode 100644 index 00000000..54983248 --- /dev/null +++ b/src/articles/dto/list-articles-query.dto.ts @@ -0,0 +1,12 @@ +import { type Static, Type } from '@sinclair/typebox'; +import { ArticleFeedQueryDto } from './article-feed-query.dto'; + +export const ListArticlesQueryDto = Type.Composite([ + ArticleFeedQueryDto, + Type.Object({ + tag: Type.Optional(Type.String()), + author: Type.Optional(Type.String()), + favorited: Type.Optional(Type.String()), + }), +]); +export type ListArticlesQueryDto = Static; diff --git a/src/articles/dto/update-article.dto.ts b/src/articles/dto/update-article.dto.ts new file mode 100644 index 00000000..d0242b0a --- /dev/null +++ b/src/articles/dto/update-article.dto.ts @@ -0,0 +1,12 @@ +import { type Static, Type } from '@sinclair/typebox'; + +// TODO: instead of defining this from scratch, can we use 'CreateArticleDto'? +export const UpdateArticleDto = Type.Object({ + article: Type.Object({ + title: Type.Optional(Type.String({ minLength: 1 })), + description: Type.Optional(Type.String({ minLength: 1 })), + body: Type.Optional(Type.String({ minLength: 1 })), + tagList: Type.Optional(Type.Array(Type.String())), + }), +}); +export type UpdateArticleDto = Static; diff --git a/src/articles/interfaces/article-feed-row.interface.ts b/src/articles/interfaces/article-feed-row.interface.ts new file mode 100644 index 00000000..19910ef7 --- /dev/null +++ b/src/articles/interfaces/article-feed-row.interface.ts @@ -0,0 +1,18 @@ +import type { ArticleRow } from './article-row.interface'; + +export type ArticleFeedRow = { + slug: string; + title: string; + description: string; + tagList: string[]; + createdAt: Date; + updatedAt: Date; + favorited: boolean; + favoritesCount: number; + author: { + username: string; + bio: string; + image: string; + following: boolean; + }; +}; diff --git a/src/articles/interfaces/article-feed.interface.ts b/src/articles/interfaces/article-feed.interface.ts new file mode 100644 index 00000000..bd978a1f --- /dev/null +++ b/src/articles/interfaces/article-feed.interface.ts @@ -0,0 +1,13 @@ +import type { ProfileFeed } from './profile-feed.interface'; + +export type IArticleFeed = { + slug: string; + title: string; + description: string; + tagList: string[]; + createdAt: Date; + updatedAt: Date; + favorited: boolean; + favoritesCount: number; + author: ProfileFeed; +}; diff --git a/src/articles/interfaces/article-row.interface.ts b/src/articles/interfaces/article-row.interface.ts new file mode 100644 index 00000000..0b9bf957 --- /dev/null +++ b/src/articles/interfaces/article-row.interface.ts @@ -0,0 +1,13 @@ +import type { Profile } from '@/profiles/profiles.schema'; +import type { ArticleTag } from '@/tags/tags.schema'; +import type { InferSelectModel } from 'drizzle-orm'; +import type { articles, favoriteArticles } from '../articles.schema'; + +type ArticleFavoritedBy = InferSelectModel; + +export interface ArticleRow + extends Omit, 'authorId'> { + author: Profile; + favoritedBy: ArticleFavoritedBy[]; + tags: ArticleTag[]; +} diff --git a/src/articles/interfaces/article.interface.ts b/src/articles/interfaces/article.interface.ts new file mode 100644 index 00000000..df12bbf9 --- /dev/null +++ b/src/articles/interfaces/article.interface.ts @@ -0,0 +1,21 @@ +import type { Profile } from '@/profiles/profiles.schema'; + +export type IArticle = { + id: number; + slug: string; + title: string; + description: string; + tagList: string[]; + body: string; + createdAt: Date; + updatedAt: Date; + favorited: boolean; + favoritesCount: number; + // TODO: This is a hack. Just define the proper type in the profiles module and use it. + author: Omit< + Profile, + 'followers' | 'id' | 'email' | 'password' | 'createdAt' | 'updatedAt' + > & { + following: boolean; + }; +}; diff --git a/src/articles/interfaces/create-article-input.interface.ts b/src/articles/interfaces/create-article-input.interface.ts new file mode 100644 index 00000000..ac75de29 --- /dev/null +++ b/src/articles/interfaces/create-article-input.interface.ts @@ -0,0 +1,6 @@ +export interface CreateArticleInput { + title: string; + description: string; + body: string; + tagList: string[]; +} diff --git a/src/articles/interfaces/index.ts b/src/articles/interfaces/index.ts new file mode 100644 index 00000000..1cb94c83 --- /dev/null +++ b/src/articles/interfaces/index.ts @@ -0,0 +1,8 @@ +export * from './article-feed.interface'; +export * from './article.interface'; +export * from './article-feed-row.interface'; +export * from './article-row.interface'; +export * from './create-article-input.interface'; +export * from './new-article-row.interface'; +export * from './update-article-input.interface'; +export * from './update-article-row.interface'; diff --git a/src/articles/interfaces/new-article-row.interface.ts b/src/articles/interfaces/new-article-row.interface.ts new file mode 100644 index 00000000..a5589c9f --- /dev/null +++ b/src/articles/interfaces/new-article-row.interface.ts @@ -0,0 +1,4 @@ +import type { articles } from '@/articles/articles.schema'; +import type { InferInsertModel } from 'drizzle-orm'; + +export type NewArticleRow = InferInsertModel; diff --git a/src/articles/interfaces/profile-feed.interface.ts b/src/articles/interfaces/profile-feed.interface.ts new file mode 100644 index 00000000..1d85600e --- /dev/null +++ b/src/articles/interfaces/profile-feed.interface.ts @@ -0,0 +1,6 @@ +export type ProfileFeed = { + username: string; + bio: string; + image: string; + following: boolean; +}; diff --git a/src/articles/interfaces/update-article-input.interface.ts b/src/articles/interfaces/update-article-input.interface.ts new file mode 100644 index 00000000..5866583a --- /dev/null +++ b/src/articles/interfaces/update-article-input.interface.ts @@ -0,0 +1,6 @@ +export interface UpdateArticleInput { + title?: string; + description?: string; + body?: string; + tagList?: string[]; +} diff --git a/src/articles/interfaces/update-article-row.interface.ts b/src/articles/interfaces/update-article-row.interface.ts new file mode 100644 index 00000000..8bf416ae --- /dev/null +++ b/src/articles/interfaces/update-article-row.interface.ts @@ -0,0 +1,3 @@ +import type { NewArticleRow } from './new-article-row.interface'; + +export type UpdateArticleRow = Partial; diff --git a/src/articles/mappers/articles.mapper.ts b/src/articles/mappers/articles.mapper.ts new file mode 100644 index 00000000..feb9e410 --- /dev/null +++ b/src/articles/mappers/articles.mapper.ts @@ -0,0 +1,159 @@ +import { slugify } from '@/utils/slugify'; +import type { CommentResponse } from '@comments/interfaces/comment-response.interface'; +import type { + ArticleResponseDto, + ArticlesResponseDto, + CreateArticleDto, +} from '../dto'; +import type { + ArticleFeedRow, + ArticleRow, + CreateArticleInput, + IArticle, + IArticleFeed, + NewArticleRow, +} from '../interfaces'; + +/** + * Additional options for the `toDomain` mapper. + */ +type ToDomainOptions = { + /** + * The tag list to use for the article. + * If provided, will be used as the updated tag list for the article, instead of the existing tag list. + */ + tagList?: string[]; + /** + * The current user id. If provided, the response will include information about the current user's favorite status for the article. + */ + currentUserId?: number; +}; + +export function toDomain( + article: ArticleRow, + { tagList, currentUserId }: ToDomainOptions, +): IArticle { + return { + id: article.id, // TODO: Do we need this? + slug: article.slug, + title: article.title, + description: article.description, + tagList: tagList ?? article.tags.map((t) => t.tagName), + createdAt: article.createdAt, + updatedAt: article.updatedAt, + favorited: !!article.favoritedBy.find( + (user) => user.userId === currentUserId, + ), + favoritesCount: article.favoritedBy.length, + body: article.body, + author: { + username: article.author.username, + bio: article.author.bio, + image: article.author.image, + following: !!article.author.followers.find( + (follower) => follower.followerId === currentUserId, + ), + }, + }; +} + +export function toResponse(article: IArticle): ArticleResponseDto { + // TODO: use the `toResponse` mapper from profiles, or make this an input + const authorProfile = { + username: article.author.username, + bio: article.author.bio, + image: article.author.image, + following: article.author.following, + }; + return { + article: { + slug: article.slug, + title: article.title, + description: article.description, + body: article.body, + tagList: article.tagList, + createdAt: article.createdAt.toISOString(), + updatedAt: article.updatedAt.toISOString(), + author: authorProfile, + favorited: article.favorited, + favoritesCount: article.favoritesCount, + }, + }; +} + +export function toCreateArticleInput({ + article, +}: CreateArticleDto): CreateArticleInput { + return { + title: article.title, + description: article.description, + body: article.body, + tagList: article.tagList ?? [], + }; +} + +export function toNewArticleRow( + input: CreateArticleInput, + authorId: number, +): NewArticleRow { + return { + ...input, + slug: slugify(input.title), + authorId, + }; +} + +export function toFeedDomain(article: ArticleFeedRow): IArticleFeed { + return { + slug: article.slug, + title: article.title, + description: article.description, + tagList: article.tagList, + createdAt: article.createdAt, + updatedAt: article.updatedAt, + favorited: article.favorited, + favoritesCount: article.favoritesCount, + author: article.author, + }; +} + +export function toFeedResponse({ + articles, + articlesCount, +}: { + articles: IArticleFeed[]; + articlesCount?: number; +}): ArticlesResponseDto { + const articlesResponse = articles.map((article) => ({ + slug: article.slug, + title: article.title, + description: article.description, + tagList: article.tagList, + createdAt: article.createdAt.toISOString(), + updatedAt: article.updatedAt.toISOString(), + favorited: article.favorited, + favoritesCount: article.favoritesCount, + author: article.author, + })); + return { + articles: articlesResponse, + articlesCount: articlesCount ?? articlesResponse.length, + }; +} + +export function toCommentResponse(comment: CommentResponse) { + return { + comment: { + id: comment.id, + body: comment.body, + createdAt: comment.createdAt.toISOString(), + updatedAt: comment.updatedAt.toISOString(), + author: { + username: comment.author.username, + bio: comment.author.bio, + image: comment.author.image, + following: comment.author.following, + }, + }, + }; +} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index c418bb5a..66f4190b 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -97,7 +97,7 @@ export class AuthService { const user = await this.getUserFromHeaders(headers); return user.id; } catch (error) { - if (error instanceof AuthenticationError) return null; + if (error instanceof AuthenticationError) return undefined; throw error; } }; diff --git a/src/articles/comments/comments.repository.ts b/src/comments/comments.repository.ts similarity index 72% rename from src/articles/comments/comments.repository.ts rename to src/comments/comments.repository.ts index ffc2435a..22653f34 100644 --- a/src/articles/comments/comments.repository.ts +++ b/src/comments/comments.repository.ts @@ -1,12 +1,20 @@ +import { articles } from '@/articles/articles.schema'; +import type { ArticleRow } from '@/articles/interfaces/article-row.interface'; import type { Database } from '@/database.providers'; -import { articles, comments } from '@articles/articles.model'; +import { comments } from '@comments/schema/comments.schema'; import { and, desc, eq } from 'drizzle-orm'; -import type { CommentToCreate } from './comments.schema'; + +// TODO: Move & Re-evaluate this type. It's really just a band-aid. +type CreateCommentDto = { + body: string; + articleId: number; + authorId: number; +}; export class CommentsRepository { constructor(private readonly db: Database) {} - async create(commentData: CommentToCreate) { + async create(commentData: CreateCommentDto) { const [comment] = await this.db .insert(comments) .values(commentData) @@ -55,12 +63,21 @@ export class CommentsRepository { return result; } - async findBySlug(slug: string) { + async findBySlug(slug: string): Promise { const result = await this.db.query.articles.findFirst({ where: eq(articles.slug, slug), + with: { + author: { + with: { + followers: true, + }, + }, + favoritedBy: true, + tags: true, + }, }); - return result; + return result ?? null; } async delete(commentId: number, authorId: number) { diff --git a/src/articles/comments/comments.service.ts b/src/comments/comments.service.ts similarity index 91% rename from src/articles/comments/comments.service.ts rename to src/comments/comments.service.ts index 0dea9753..6ad490a2 100644 --- a/src/articles/comments/comments.service.ts +++ b/src/comments/comments.service.ts @@ -2,7 +2,14 @@ import { AuthorizationError, BadRequestError } from '@errors'; import type { ProfilesService } from '@profiles/profiles.service'; import { NotFoundError } from 'elysia'; import type { CommentsRepository } from './comments.repository'; -import type { CommentToCreate, ReturnedComment } from './comments.schema'; +import type { CommentResponse } from './interfaces/comment-response.interface'; + +// TODO: Move & Re-evaluate this type. It's really just a band-aid. +type CommentToCreate = { + body: string; + articleId: number; + authorId: number; +}; export class CommentsService { constructor( @@ -14,7 +21,7 @@ export class CommentsService { articleSlug: string, commentBody: { body: string }, userId: number, - ): Promise { + ): Promise { const article = await this.commentsRepository.findBySlug(articleSlug); if (!article) { @@ -51,7 +58,7 @@ export class CommentsService { async getComments( articleSlug: string, currentUserId?: number, - ): Promise { + ): Promise { const article = await this.commentsRepository.findBySlug(articleSlug); if (!article) { diff --git a/src/comments/dto/comment-author.dto.ts b/src/comments/dto/comment-author.dto.ts new file mode 100644 index 00000000..c4906516 --- /dev/null +++ b/src/comments/dto/comment-author.dto.ts @@ -0,0 +1,8 @@ +import { Type } from '@sinclair/typebox'; + +export const CommentAuthorDto = Type.Object({ + username: Type.String(), + bio: Type.String(), + image: Type.String(), + following: Type.Boolean(), +}); diff --git a/src/comments/dto/comment-response.dto.ts b/src/comments/dto/comment-response.dto.ts new file mode 100644 index 00000000..6d331b09 --- /dev/null +++ b/src/comments/dto/comment-response.dto.ts @@ -0,0 +1,12 @@ +import { Type } from '@sinclair/typebox'; +import { CommentAuthorDto } from './comment-author.dto'; + +export const CommentResponseDto = Type.Object({ + comment: Type.Object({ + id: Type.Number(), + body: Type.String(), + createdAt: Type.String(), + updatedAt: Type.String(), + author: CommentAuthorDto, + }), +}); diff --git a/src/comments/dto/create-comment.dto.ts b/src/comments/dto/create-comment.dto.ts new file mode 100644 index 00000000..f4dccbc0 --- /dev/null +++ b/src/comments/dto/create-comment.dto.ts @@ -0,0 +1,8 @@ +import { type Static, Type } from '@sinclair/typebox'; + +export const CreateCommentDto = Type.Object({ + comment: Type.Object({ + body: Type.String({ minLength: 1 }), + }), +}); +export type CreateCommentDto = Static; diff --git a/src/comments/dto/index.ts b/src/comments/dto/index.ts new file mode 100644 index 00000000..a6f73d06 --- /dev/null +++ b/src/comments/dto/index.ts @@ -0,0 +1,3 @@ +export * from './create-comment.dto'; +export * from './comment-response.dto'; +export * from './comment-author.dto'; diff --git a/src/comments/interfaces/comment-response.interface.ts b/src/comments/interfaces/comment-response.interface.ts new file mode 100644 index 00000000..536cdded --- /dev/null +++ b/src/comments/interfaces/comment-response.interface.ts @@ -0,0 +1,9 @@ +import type { ProfileFeed } from '@/articles/interfaces/profile-feed.interface'; + +export type CommentResponse = { + id: number; + body: string; + createdAt: Date; + updatedAt: Date; + author: ProfileFeed; +}; diff --git a/src/comments/schema/comment-relations.schema.ts b/src/comments/schema/comment-relations.schema.ts new file mode 100644 index 00000000..55fcd458 --- /dev/null +++ b/src/comments/schema/comment-relations.schema.ts @@ -0,0 +1,17 @@ +import { articles } from '@/articles/articles.schema'; +import { users } from '@users/users.model'; +import { relations } from 'drizzle-orm'; +import { comments } from './comments.schema'; + +export const commentRelations = relations(comments, ({ one }) => ({ + article: one(articles, { + fields: [comments.articleId], + references: [articles.id], + relationName: 'articleComments', + }), + author: one(users, { + fields: [comments.authorId], + references: [users.id], + relationName: 'commentAuthor', + }), +})); diff --git a/src/comments/schema/comments.schema.ts b/src/comments/schema/comments.schema.ts new file mode 100644 index 00000000..e66c8d32 --- /dev/null +++ b/src/comments/schema/comments.schema.ts @@ -0,0 +1,17 @@ +import { articles } from '@/articles/articles.schema'; +import { users } from '@users/users.model'; +import { sql } from 'drizzle-orm'; +import { integer, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; + +export const comments = pgTable('comments', { + id: serial('id').primaryKey().notNull(), + body: text('body').notNull(), + articleId: integer('article_id') + .references(() => articles.id, { onDelete: 'cascade' }) + .notNull(), + authorId: integer('author_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + createdAt: timestamp('created_at').default(sql`CURRENT_TIMESTAMP`).notNull(), + updatedAt: timestamp('updated_at').default(sql`CURRENT_TIMESTAMP`).notNull(), +}); diff --git a/src/comments/schema/index.ts b/src/comments/schema/index.ts new file mode 100644 index 00000000..43ccad84 --- /dev/null +++ b/src/comments/schema/index.ts @@ -0,0 +1,2 @@ +export * from './comments.schema'; +export * from './comment-relations.schema'; diff --git a/src/database.providers.ts b/src/database.providers.ts index 2656a9df..37a29a5d 100644 --- a/src/database.providers.ts +++ b/src/database.providers.ts @@ -1,4 +1,5 @@ -import * as articlesSchema from '@articles/articles.model'; +import * as articlesSchema from '@articles/articles.schema'; +import * as commentsSchema from '@comments/schema'; import { dbCredentialsString } from '@db/config'; import * as tagsSchema from '@tags/tags.model'; import * as usersSchema from '@users/users.model'; @@ -10,7 +11,12 @@ export const migrationsClient = postgres(dbCredentialsString, { max: 1 }); export const queryClient = postgres(dbCredentialsString); export const db = drizzle(queryClient, { - schema: { ...usersSchema, ...articlesSchema, ...tagsSchema }, + schema: { + ...usersSchema, + ...articlesSchema, + ...tagsSchema, + ...commentsSchema, + }, logger: true, }); export type Database = typeof db; diff --git a/src/errors.ts b/src/errors.ts index bf48583d..b2f79ab6 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,4 +1,5 @@ import { DEFAULT, MapWithDefault } from '@utils/defaultmap'; +import { Elysia } from 'elysia'; export class AuthenticationError extends Error { public status = 401; @@ -13,6 +14,7 @@ export class AuthorizationError extends Error { public type = 'authorization'; constructor(public message: string) { super(message); + this.name = 'AuthorizationError'; } } @@ -21,6 +23,16 @@ export class BadRequestError extends Error { public type = 'bad_request'; constructor(public message: string) { super(message); + this.name = 'BadRequestError'; + } +} + +export class ConflictError extends Error { + public status = 409; + public type = 'conflict'; + constructor(message: string) { + super(message); + this.name = 'ConflictError'; } } @@ -32,6 +44,7 @@ const ERROR_CODE_STATUS_MAP = new MapWithDefault([ ['INVALID_COOKIE_SIGNATURE', 401], ['AUTHENTICATION', 401], ['AUTHORIZATION', 403], + ['CONFLICT', 409], ['INTERNAL_SERVER_ERROR', 500], ['UNKNOWN', 500], [DEFAULT, 500], @@ -43,3 +56,27 @@ export function getErrorStatusFromCode(code: string | number): number { ERROR_CODE_STATUS_MAP.get(DEFAULT) ); } + +export const errorHandler = new Elysia().onError(({ code, error, set }) => { + if (error instanceof AuthorizationError) { + set.status = 403; + return { errors: { body: [error.message] } }; + } + + if (error instanceof BadRequestError) { + set.status = 400; + return { errors: { body: [error.message] } }; + } + + if (error instanceof ConflictError) { + set.status = 409; + return { errors: { body: [error.message] } }; + } + + set.status = 500; + return { + errors: { + internal: 'message' in error ? error.message : 'Internal server error', + }, + }; +}); diff --git a/src/profiles/profiles.plugin.ts b/src/profiles/profiles.plugin.ts index 43231393..56466f88 100644 --- a/src/profiles/profiles.plugin.ts +++ b/src/profiles/profiles.plugin.ts @@ -22,7 +22,7 @@ export const profilesPlugin = new Elysia().use(setupProfiles).group( beforeHandle: app.store.authService.requireLogin, response: ReturnedProfileSchema, detail: { - summary: 'Profile', + summary: 'Get Profile', security: [ { tokenAuth: [], @@ -35,14 +35,14 @@ export const profilesPlugin = new Elysia().use(setupProfiles).group( '/follow', async ({ params, store, request }) => store.profilesService.followUser( - await store.authService.getUserIdFromHeader(request.headers), params.username, + await store.authService.getUserIdFromHeader(request.headers), ), { beforeHandle: app.store.authService.requireLogin, response: ReturnedProfileSchema, detail: { - summary: 'Follow Profile', + summary: 'Follow user', security: [ { tokenAuth: [], @@ -56,14 +56,14 @@ export const profilesPlugin = new Elysia().use(setupProfiles).group( '/follow', async ({ params, store, request }) => store.profilesService.unfollowUser( - await store.authService.getUserIdFromHeader(request.headers), params.username, + await store.authService.getUserIdFromHeader(request.headers), ), { beforeHandle: app.store.authService.requireLogin, response: ReturnedProfileSchema, detail: { - summary: 'Unfollow Profile', + summary: 'Unfollow user', security: [ { tokenAuth: [], diff --git a/src/profiles/profiles.repository.ts b/src/profiles/profiles.repository.ts index d2bb0e4d..501a84be 100644 --- a/src/profiles/profiles.repository.ts +++ b/src/profiles/profiles.repository.ts @@ -27,6 +27,15 @@ export class ProfilesRepository { return result[0]; } + async findFollowedUserIds(currentUserId: number): Promise { + const results = await this.db + .select({ followedId: userFollows.followedId }) + .from(userFollows) + .where(eq(userFollows.followerId, currentUserId)); + + return results.map((r) => r.followedId); + } + async followUser(currentUserId: number, userToFollow: number) { const result = await this.db .insert(userFollows) @@ -47,4 +56,21 @@ export class ProfilesRepository { .returning(); return result[0]; } + + async findFollowByUsers( + followedId: number, + followerId: number, + ): Promise { + const result = await this.db + .select({ id: userFollows.followedId }) + .from(userFollows) + .where( + and( + eq(userFollows.followedId, followedId), + eq(userFollows.followerId, followerId), + ), + ) + .limit(1); + return result.length > 0; + } } diff --git a/src/profiles/profiles.service.ts b/src/profiles/profiles.service.ts index 01bfea60..dd0fd7e7 100644 --- a/src/profiles/profiles.service.ts +++ b/src/profiles/profiles.service.ts @@ -1,3 +1,5 @@ +import { ConflictError } from '@/errors'; +import type { IProfile } from '@profiles/interfaces'; import type { ProfilesRepository } from '@profiles/profiles.repository'; import type { ParsedProfileSchema, Profile } from '@profiles/profiles.schema'; import type { UsersRepository } from '@users/users.repository'; @@ -25,39 +27,46 @@ export class ProfilesService { return await this.generateProfileResponse(user, currentUserId); } - async followUser(currentUserId: number, targetUsername: string) { - const userToFollow = - await this.usersRepository.findByUsername(targetUsername); + async followUser( + username: string, + currentUserId: number, + ): Promise { + const userToFollow = await this.repository.findByUsername(username); if (!userToFollow) { - throw new NotFoundError('Profile not found'); + throw new NotFoundError('User not found'); } - await this.repository.followUser(currentUserId, userToFollow.id); - - const followedProfile = - await this.repository.findByUsername(targetUsername); - if (!followedProfile) { - throw new NotFoundError('Profile not found'); + // Check if already following + const isFollowing = await this.repository.findFollowByUsers( + userToFollow.id, + currentUserId, + ); + if (!isFollowing) { + await this.repository.followUser(currentUserId, userToFollow.id); } - return await this.generateProfileResponse(followedProfile, currentUserId); + return this.findByUsername(currentUserId, username); } - async unfollowUser(currentUserId: number, targetUsername: string) { - const userToUnfollow = await this.repository.findByUsername(targetUsername); + async unfollowUser( + username: string, + currentUserId: number, + ): Promise { + const userToUnfollow = await this.repository.findByUsername(username); if (!userToUnfollow) { throw new NotFoundError('Profile not found'); } - await this.repository.unfollowUser(currentUserId, userToUnfollow.id); - - const unfollowedProfile = - await this.repository.findByUsername(targetUsername); - if (!unfollowedProfile) { - throw new NotFoundError('Profile not found'); + // Check if following before attempting to unfollow + const isFollowing = await this.repository.findFollowByUsers( + userToUnfollow.id, + currentUserId, + ); + if (isFollowing) { + await this.repository.unfollowUser(currentUserId, userToUnfollow.id); } - return await this.generateProfileResponse(unfollowedProfile, currentUserId); + return this.findByUsername(currentUserId, username); } async generateProfileResponse( @@ -78,4 +87,8 @@ export class ProfilesService { }, }; } + + async findFollowedUserIds(currentUserId: number): Promise { + return this.repository.findFollowedUserIds(currentUserId); + } } diff --git a/src/tags/tags.model.ts b/src/tags/tags.model.ts index 5cc6306f..44f45973 100644 --- a/src/tags/tags.model.ts +++ b/src/tags/tags.model.ts @@ -1,4 +1,4 @@ -import { articles } from '@articles/articles.model'; +import { articles } from '@/articles/articles.schema'; import { relations } from 'drizzle-orm'; import { integer, diff --git a/src/tags/tags.plugin.ts b/src/tags/tags.plugin.ts index f4f32d0a..74aa0e65 100644 --- a/src/tags/tags.plugin.ts +++ b/src/tags/tags.plugin.ts @@ -13,7 +13,7 @@ export const tagsPlugin = new Elysia().use(setupTags).group( app.get('/', async ({ store }) => store.tagsService.getTags(), { response: ListTagsResponseSchema, detail: { - summary: 'List Tags', + summary: 'Get Tags', }, }), ); diff --git a/src/users/dto/index.ts b/src/users/dto/index.ts new file mode 100644 index 00000000..f5678d55 --- /dev/null +++ b/src/users/dto/index.ts @@ -0,0 +1 @@ +export * from './user-response.dto'; diff --git a/src/users/dto/user-response.dto.ts b/src/users/dto/user-response.dto.ts new file mode 100644 index 00000000..aa138f56 --- /dev/null +++ b/src/users/dto/user-response.dto.ts @@ -0,0 +1,11 @@ +import { Type } from '@sinclair/typebox'; + +export const UserResponseDto = Type.Object({ + id: Type.Number(), + username: Type.String(), + email: Type.String(), + bio: Type.String(), + image: Type.String(), + createdAt: Type.String(), + updatedAt: Type.String(), +}); diff --git a/src/users/index.ts b/src/users/index.ts new file mode 100644 index 00000000..f82630d9 --- /dev/null +++ b/src/users/index.ts @@ -0,0 +1 @@ +export { UserResponseDto } from './dto'; diff --git a/src/users/users.model.ts b/src/users/users.model.ts index b43a1e79..b374c196 100644 --- a/src/users/users.model.ts +++ b/src/users/users.model.ts @@ -1,4 +1,4 @@ -import { articles, favoriteArticles } from '@/articles/articles.model'; +import { articles, favoriteArticles } from '@/articles/articles.schema'; import { relations, sql } from 'drizzle-orm'; import { date, diff --git a/src/users/users.plugin.ts b/src/users/users.plugin.ts index f0a296d1..25308257 100644 --- a/src/users/users.plugin.ts +++ b/src/users/users.plugin.ts @@ -25,7 +25,7 @@ export const usersPlugin = new Elysia() body: InsertUserSchema, response: ReturnedUserSchema, detail: { - summary: 'Register', + summary: 'Registration', }, }, ) @@ -37,7 +37,7 @@ export const usersPlugin = new Elysia() body: UserLoginSchema, response: ReturnedUserSchema, detail: { - summary: 'Login', + summary: 'Authentication', }, }, ), @@ -54,7 +54,7 @@ export const usersPlugin = new Elysia() beforeHandle: app.store.authService.requireLogin, response: ReturnedUserSchema, detail: { - summary: 'Current User', + summary: 'Get Current User', security: [ { tokenAuth: [], diff --git a/src/users/users.schema.ts b/src/users/users.schema.ts index 2241baf1..39a8dfb6 100644 --- a/src/users/users.schema.ts +++ b/src/users/users.schema.ts @@ -1,6 +1,7 @@ import { type Static, Type } from '@sinclair/typebox'; // Do not use path aliases here (i.e. '@/users/users.model'), as that doesn't work with Drizzle Studio import { type userFollows, users } from '@users/users.model'; +import type { InferSelectModel } from 'drizzle-orm'; import { createInsertSchema, createSelectSchema } from 'drizzle-typebox'; // Schema for inserting a user - can be used to validate API requests @@ -49,4 +50,4 @@ export const UserLoginSchema = Type.Object({ export const selectUserSchemaRaw = createSelectSchema(users); export const SelectUserSchema = Type.Omit(selectUserSchemaRaw, ['password']); -export type FollowerSchema = typeof userFollows.$inferSelect; +export type FollowerSchema = InferSelectModel; diff --git a/tsconfig.json b/tsconfig.json index 3f8eb130..3c0543e4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,8 +38,10 @@ "@profiles/*": ["./src/profiles/*"], "@utils/*": ["./src/utils/*"], "@articles/*": ["./src/articles/*"], + "@comments/*": ["./src/comments/*"], "@tags/*": ["./src/tags/*"], "@errors": ["./src/errors.ts"], + "@constants": ["./src/constants.ts"], "@config": ["./src/config.ts"] }, // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */