diff --git a/apps/angular-app/src/app/app.component.html b/apps/angular-app/src/app/app.component.html index 0680b43f..9721b000 100644 --- a/apps/angular-app/src/app/app.component.html +++ b/apps/angular-app/src/app/app.component.html @@ -1 +1,2 @@ + diff --git a/apps/angular-app/src/app/app.component.ts b/apps/angular-app/src/app/app.component.ts index 74c1d539..1d489374 100644 --- a/apps/angular-app/src/app/app.component.ts +++ b/apps/angular-app/src/app/app.component.ts @@ -1,9 +1,11 @@ import { Component } from '@angular/core'; import { RouterModule } from '@angular/router'; +import { HeaderComponent } from './components/header/header.component'; + @Component({ standalone: true, - imports: [RouterModule], + imports: [HeaderComponent, RouterModule], selector: 'mfee-project-root', templateUrl: './app.component.html', styleUrl: './app.component.scss' diff --git a/apps/angular-app/src/app/app.config.ts b/apps/angular-app/src/app/app.config.ts index 0cf96af2..17b41722 100644 --- a/apps/angular-app/src/app/app.config.ts +++ b/apps/angular-app/src/app/app.config.ts @@ -1,8 +1,9 @@ +import { provideHttpClient } from '@angular/common/http'; import { ApplicationConfig } from '@angular/core'; import { provideRouter } from '@angular/router'; import { appRoutes } from './app.routes'; export const appConfig: ApplicationConfig = { - providers: [provideRouter(appRoutes)] + providers: [provideRouter(appRoutes), provideHttpClient()] }; diff --git a/apps/angular-app/src/app/app.routes.ts b/apps/angular-app/src/app/app.routes.ts index 8762dfe2..75bb3733 100644 --- a/apps/angular-app/src/app/app.routes.ts +++ b/apps/angular-app/src/app/app.routes.ts @@ -1,3 +1,22 @@ import { Route } from '@angular/router'; -export const appRoutes: Route[] = []; +import { HomeComponent } from './views/home/home.component'; +import { PageNotFoundComponent } from './views/page-not-found/page-not-found.component'; +import { PostComponent } from './views/post/post.component'; + +export const appRoutes: Route[] = [ + { + path: '', + pathMatch: 'full', + component: HomeComponent + }, + { + path: 'post/:id', + component: PostComponent + }, + { + path: '**', + component: PageNotFoundComponent + } +]; + diff --git a/apps/angular-app/src/app/components/add-post/add-post.component.html b/apps/angular-app/src/app/components/add-post/add-post.component.html new file mode 100644 index 00000000..4bf91363 --- /dev/null +++ b/apps/angular-app/src/app/components/add-post/add-post.component.html @@ -0,0 +1,3 @@ + diff --git a/apps/angular-app/src/app/components/add-post/add-post.component.scss b/apps/angular-app/src/app/components/add-post/add-post.component.scss new file mode 100644 index 00000000..79728ece --- /dev/null +++ b/apps/angular-app/src/app/components/add-post/add-post.component.scss @@ -0,0 +1,16 @@ +.mfee-add-post-button { + position: fixed; + width: 3rem; + height: 3rem; + border-radius: 99px; + background-color: orange; + border: none; + right: 1rem; + top: 3rem; + box-shadow: 0px 8px 15px rgba(0, 0, 0, 0.1); + cursor: pointer; + color: white; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/apps/angular-app/src/app/components/add-post/add-post.component.spec.ts b/apps/angular-app/src/app/components/add-post/add-post.component.spec.ts new file mode 100644 index 00000000..27a3f263 --- /dev/null +++ b/apps/angular-app/src/app/components/add-post/add-post.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AddPostComponent } from './add-post.component'; + +describe('AddPostComponent', () => { + let component: AddPostComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AddPostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AddPostComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/add-post/add-post.component.ts b/apps/angular-app/src/app/components/add-post/add-post.component.ts new file mode 100644 index 00000000..601c67d4 --- /dev/null +++ b/apps/angular-app/src/app/components/add-post/add-post.component.ts @@ -0,0 +1,19 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Output } from '@angular/core'; + +@Component({ + selector: 'mfee-add-post', + standalone: true, + imports: [CommonModule], + templateUrl: './add-post.component.html', + styleUrl: './add-post.component.scss' +}) +export class AddPostComponent { + @Output() clickAction: EventEmitter = new EventEmitter(); + + constructor() {} + + public addPost(): void { + this.clickAction.emit(); + } +} diff --git a/apps/angular-app/src/app/components/categories/categories.component.html b/apps/angular-app/src/app/components/categories/categories.component.html new file mode 100644 index 00000000..ec7d7a0f --- /dev/null +++ b/apps/angular-app/src/app/components/categories/categories.component.html @@ -0,0 +1,8 @@ +
+
    +
  • + {{ category.name }} +
  • +
+
\ No newline at end of file diff --git a/apps/angular-app/src/app/components/categories/categories.component.scss b/apps/angular-app/src/app/components/categories/categories.component.scss new file mode 100644 index 00000000..1271c17a --- /dev/null +++ b/apps/angular-app/src/app/components/categories/categories.component.scss @@ -0,0 +1,47 @@ +.mfee-category-container { + display: flex; + justify-content: center; +} + +.mfee-category { + list-style: none; + display: flex; + padding: 0; + + @media (max-width: 767px) { + flex-direction: column; + width: 95%; + } + + &__item { + padding: 0.6rem 1.2rem; + border: 1px solid #ccc; + border-bottom: none; + cursor: pointer; + + &:last-of-type { + border-bottom: 1px solid #ccc; + } + + @media (min-width: 767px) { + border-right: none; + border-bottom: 1px solid #ccc; + + &:first-of-type { + border-top-left-radius: 0.2rem; + border-bottom-left-radius: 0.2rem; + } + + &:last-of-type { + border-right: 1px solid #ccc; + border-top-right-radius: 0.2rem; + border-bottom-right-radius: 0.2rem; + } + } + + &--active, + &:hover { + background-color: #ccc; + } + } +} diff --git a/apps/angular-app/src/app/components/categories/categories.component.spec.ts b/apps/angular-app/src/app/components/categories/categories.component.spec.ts new file mode 100644 index 00000000..ccd269ad --- /dev/null +++ b/apps/angular-app/src/app/components/categories/categories.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CategoriesComponent } from './categories.component'; + +describe('CategoriesComponent', () => { + let component: CategoriesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CategoriesComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(CategoriesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/categories/categories.component.ts b/apps/angular-app/src/app/components/categories/categories.component.ts new file mode 100644 index 00000000..a93c03bb --- /dev/null +++ b/apps/angular-app/src/app/components/categories/categories.component.ts @@ -0,0 +1,21 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +import { Category } from '../../models/Category'; + +@Component({ + selector: 'mfee-categories', + standalone: true, + imports: [CommonModule], + templateUrl: './categories.component.html', + styleUrl: './categories.component.scss' +}) +export class CategoriesComponent { + @Input() selectedCategory: string; + @Input() categories: Array; + @Output() categoryChange: EventEmitter = new EventEmitter(); + + setCategory(categoryId: string) { + this.categoryChange.emit(categoryId) + } +} diff --git a/apps/angular-app/src/app/components/header/header.component.html b/apps/angular-app/src/app/components/header/header.component.html new file mode 100644 index 00000000..9c004bb4 --- /dev/null +++ b/apps/angular-app/src/app/components/header/header.component.html @@ -0,0 +1,8 @@ +
+
+ [ + + ] +
+

Discovering the World

+
\ No newline at end of file diff --git a/apps/angular-app/src/app/components/header/header.component.scss b/apps/angular-app/src/app/components/header/header.component.scss new file mode 100644 index 00000000..b5ef0ad0 --- /dev/null +++ b/apps/angular-app/src/app/components/header/header.component.scss @@ -0,0 +1,22 @@ +.mfee-header { + display: flex; + flex-direction: column; + align-items: center; + padding: 1.5rem 0; + text-align: center; + + &__slogan { + color: orange; + font-size: 0.8rem; + } + + &__slogan-icon { + font-size: 2em; + padding: 0 0.5rem; + } + + &__title { + margin: 0; + font-size: 2.5rem; + } +} diff --git a/apps/angular-app/src/app/components/header/header.component.spec.ts b/apps/angular-app/src/app/components/header/header.component.spec.ts new file mode 100644 index 00000000..3096a746 --- /dev/null +++ b/apps/angular-app/src/app/components/header/header.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HeaderComponent } from './header.component'; + +describe('HeaderComponent', () => { + let component: HeaderComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HeaderComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(HeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/header/header.component.ts b/apps/angular-app/src/app/components/header/header.component.ts new file mode 100644 index 00000000..6d240ee4 --- /dev/null +++ b/apps/angular-app/src/app/components/header/header.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'mfee-header', + standalone: true, + imports: [CommonModule], + templateUrl: './header.component.html', + styleUrl: './header.component.scss', +}) +export class HeaderComponent {} diff --git a/apps/angular-app/src/app/components/post-grid/post-grid.component.html b/apps/angular-app/src/app/components/post-grid/post-grid.component.html new file mode 100644 index 00000000..26d5fbcb --- /dev/null +++ b/apps/angular-app/src/app/components/post-grid/post-grid.component.html @@ -0,0 +1,19 @@ +
+
+

{{ post.title }}

+ {{ post.comments.count }} Comments + forum +

{{ post.description }}

+ +
+
\ No newline at end of file diff --git a/apps/angular-app/src/app/components/post-grid/post-grid.component.scss b/apps/angular-app/src/app/components/post-grid/post-grid.component.scss new file mode 100644 index 00000000..abfd2011 --- /dev/null +++ b/apps/angular-app/src/app/components/post-grid/post-grid.component.scss @@ -0,0 +1,95 @@ +@import '../../shared/variables'; + +.mfee-grid-container { + display: flex; + flex-wrap: wrap; +} + +.mfee-grid-post { + $post: &; + + background-position: center; + background-repeat: no-repeat; + background-size: cover; + box-sizing: border-box; + color: #fff; + display: flex; + flex-direction: column; + justify-content: flex-end; + min-height: 40vh; + padding: 1.2rem; + text-decoration: none; + background-color: $color-gray; + + @media (min-width: 768px) { + width: 50%; + } + + &:hover { + #{$post}__actions { + display: flex; + } + } + + &__title { + } + + &__comments { + font-weight: lighter; + font-style: italic; + align-items: flex-end; + display: flex; + + i { + font-size: inherit; + margin-left: 0.4rem; + } + } + + &__description { + font-size: 0.875rem; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + + @media (min-width: 768px) { + width: 75%; + } + } + + &__footer { + display: flex; + justify-content: space-between; + min-height: 1.75rem; + } + + &__actions { + list-style: none; + padding: 0; + margin: 0; + display: none; + } + + &__actions-item { + width: 1.75rem; + height: 1.75rem; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + cursor: pointer; + color: #ccc; + } + } + + &__tag { + font-size: 0.875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 3px; + align-items: flex-end; + display: flex; + } +} diff --git a/apps/angular-app/src/app/components/post-grid/post-grid.component.spec.ts b/apps/angular-app/src/app/components/post-grid/post-grid.component.spec.ts new file mode 100644 index 00000000..05eeac02 --- /dev/null +++ b/apps/angular-app/src/app/components/post-grid/post-grid.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PostGridComponent } from './post-grid.component'; + +describe('PostGridComponent', () => { + let component: PostGridComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PostGridComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PostGridComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/post-grid/post-grid.component.ts b/apps/angular-app/src/app/components/post-grid/post-grid.component.ts new file mode 100644 index 00000000..2dc2517f --- /dev/null +++ b/apps/angular-app/src/app/components/post-grid/post-grid.component.ts @@ -0,0 +1,37 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Router } from '@angular/router'; + +import { Post } from '../../models/Post'; + +@Component({ + selector: 'mfee-post-grid', + standalone: true, + imports: [CommonModule], + templateUrl: './post-grid.component.html', + styleUrl: './post-grid.component.scss' +}) +export class PostGridComponent { + @Input() posts: Array; + @Output() deletePost: EventEmitter = new EventEmitter(); + @Output() editPost: EventEmitter = new EventEmitter(); + + constructor(private router: Router) {} + + public onClick(event, postId: string): void { + if ( + !event.target.className.includes('mfee-grid-post__actions-item') && + !event.target.parentNode.className.includes('mfee-grid-post__actions-item') + ) { + this.router.navigate(['/post', postId]); + } + } + + public onEditPost(postId: string): void { + this.editPost.emit(postId); + } + + public onDeletePost(postId: string): void { + this.deletePost.emit(postId); + } +} diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.component.html b/apps/angular-app/src/app/components/post-modal/post-modal.component.html new file mode 100644 index 00000000..f73e8bc4 --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.component.html @@ -0,0 +1,61 @@ +
+
+

{{ postModalService.title$ | async }}

+ +
+ + + + + + + + + +
+
+
\ No newline at end of file diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.component.scss b/apps/angular-app/src/app/components/post-modal/post-modal.component.scss new file mode 100644 index 00000000..2456c850 --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.component.scss @@ -0,0 +1,38 @@ +@import '../../shared/variables'; + +.mfee-modal { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + + &__content { + background-color: white; + width: 90vw; + padding: 1.5rem; + + @media (min-width: 768px) { + width: 60vw; + } + } + + &__form { + display: flex; + flex-direction: column; + } + + &__title { + margin-top: 0; + text-align: center; + } + + &__footer { + display: flex; + justify-content: flex-end; + } +} diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.component.spec.ts b/apps/angular-app/src/app/components/post-modal/post-modal.component.spec.ts new file mode 100644 index 00000000..30308527 --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PostModalComponent } from './post-modal.component'; + +describe('PostModalComponent', () => { + let component: PostModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PostModalComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PostModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.component.ts b/apps/angular-app/src/app/components/post-modal/post-modal.component.ts new file mode 100644 index 00000000..e5065fce --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.component.ts @@ -0,0 +1,97 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { Observable, ReplaySubject, firstValueFrom, switchMap, take, takeUntil, tap } from 'rxjs'; +import { Category } from '../../models/Category'; +import { CategoryService } from '../../services/category/category.service'; +import { PostService } from '../../services/post/post.service'; +import { urlValidator } from '../../shared/url-validator'; +import { PostModalService } from './post-modal.service'; + +@Component({ + selector: 'mfee-post-modal', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './post-modal.component.html', + styleUrl: './post-modal.component.scss' +}) +export class PostModalComponent implements OnInit, OnDestroy { + private destroyed$: ReplaySubject = new ReplaySubject(1); + public categories$: Observable; + + postForm = this.fb.group({ + title: ['', Validators.required], + description: ['', Validators.required], + category: ['', Validators.required], + image: ['', [Validators.required, urlValidator]] + }); + + constructor( + public postModalService: PostModalService, + private categoryService: CategoryService, + private postService: PostService, + private fb: FormBuilder + ) {} + + ngOnInit(): void { + this.categories$ = this.categoryService.getCategories(false); + + this.postModalService.post$.pipe(takeUntil(this.destroyed$)).subscribe((post) => { + this.postForm.setValue({ + title: post?.title ?? '', + description: post?.description ?? '', + category: post?.category.id ?? '', + image: post?.image ?? '' + }); + }); + } + + ngOnDestroy() { + this.destroyed$.next(true); + this.destroyed$.complete(); + } + + close(): void { + this.postModalService.close(); + } + + async onSubmit(): Promise { + await firstValueFrom( + this.postModalService.post$.pipe( + take(1), + switchMap((post) => { + const payload = { + ...this.postForm.value + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + return this.postService.upsertPost(post ? { ...post, ...payload } : payload); + }), + tap(() => { + console.log('Update posts'); + // TODO : Update posts + }) + ) + ); + + this.postForm.reset(); + this.close(); + } + + get title() { + return this.postForm.get('title'); + } + + get description() { + return this.postForm.get('description'); + } + + get category() { + return this.postForm.get('category'); + } + + get image() { + return this.postForm.get('image'); + } +} diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.service.spec.ts b/apps/angular-app/src/app/components/post-modal/post-modal.service.spec.ts new file mode 100644 index 00000000..2ad8a208 --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { PostModalService } from './post-modal.service'; + +describe('PostModalService', () => { + let service: PostModalService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(PostModalService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.service.ts b/apps/angular-app/src/app/components/post-modal/post-modal.service.ts new file mode 100644 index 00000000..a1515a83 --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; + +import { Post } from '../../models/Post'; + +@Injectable({ + providedIn: 'root' +}) +export class PostModalService { + private display: BehaviorSubject = new BehaviorSubject(false); + public display$: Observable = this.display.asObservable(); + + private title: BehaviorSubject = new BehaviorSubject(''); + public title$: Observable = this.title.asObservable(); + + private post: BehaviorSubject = new BehaviorSubject(null); + public post$: Observable = this.post.asObservable(); + + constructor() {} + + open(): void { + this.display.next(true); + } + + close(): void { + this.display.next(false); + } + + setInfo(title: string, post: Post = null): void { + this.title.next(title); + this.post.next(post); + } +} diff --git a/apps/angular-app/src/app/models/Category.ts b/apps/angular-app/src/app/models/Category.ts new file mode 100644 index 00000000..0df829f8 --- /dev/null +++ b/apps/angular-app/src/app/models/Category.ts @@ -0,0 +1,9 @@ +export type Category = { + id: string; + name: string; +}; + +export type GetCategoryResponse = { + _id: string; + name: string; +}; diff --git a/apps/angular-app/src/app/models/Comment.ts b/apps/angular-app/src/app/models/Comment.ts new file mode 100644 index 00000000..3f74e4a2 --- /dev/null +++ b/apps/angular-app/src/app/models/Comment.ts @@ -0,0 +1,11 @@ +export type Comment = { + id: string; + author: string; + content: string; +}; + +export type CommentResponse = { + _id: string; + author: string; + content: string; +}; diff --git a/apps/angular-app/src/app/models/Post.ts b/apps/angular-app/src/app/models/Post.ts new file mode 100644 index 00000000..94d33653 --- /dev/null +++ b/apps/angular-app/src/app/models/Post.ts @@ -0,0 +1,32 @@ +import { Category, GetCategoryResponse } from './Category'; +import { Comment, CommentResponse } from './Comment'; + +export type Post = { + id: string; + title: string; + image: string; + description: string; + category: Category; + comments: { + count: number; + data?: Array; + }; +}; + +export type GetPostsResponse = { + _id: string; + title: string; + image: string; + description: string; + category: GetCategoryResponse; + comments: Array; +}; + +export type GetPostResponse = { + _id: string; + title: string; + image: string; + description: string; + category: GetCategoryResponse; + comments: Array; +}; diff --git a/apps/angular-app/src/app/services/.gitkeep b/apps/angular-app/src/app/services/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/angular-app/src/app/services/category/category.service.spec.ts b/apps/angular-app/src/app/services/category/category.service.spec.ts new file mode 100644 index 00000000..56585079 --- /dev/null +++ b/apps/angular-app/src/app/services/category/category.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { CategoryService } from './category.service'; + +describe('CategoryService', () => { + let service: CategoryService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(CategoryService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/services/category/category.service.ts b/apps/angular-app/src/app/services/category/category.service.ts new file mode 100644 index 00000000..cf55e192 --- /dev/null +++ b/apps/angular-app/src/app/services/category/category.service.ts @@ -0,0 +1,33 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, map, take } from 'rxjs'; + +import { Category, GetCategoryResponse } from '../../models/Category'; + +@Injectable({ + providedIn: 'root' +}) +export class CategoryService { + private apiUrl = 'http://localhost:4200/api/categories'; + + constructor(private http: HttpClient) {} + + public getCategories(includeAll: boolean = true): Observable { + return this.http.get(this.apiUrl).pipe( + take(1), + map((categories) => { + const newCategories = categories.map((c) => ({ id: c._id, name: c.name })); + + return includeAll + ? [ + { + id: 'all', + name: 'All' + }, + ...newCategories + ] + : newCategories; + }) + ); + } +} diff --git a/apps/angular-app/src/app/services/post/post.service.spec.ts b/apps/angular-app/src/app/services/post/post.service.spec.ts new file mode 100644 index 00000000..913642b8 --- /dev/null +++ b/apps/angular-app/src/app/services/post/post.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { PostService } from './post.service'; + +describe('PostService', () => { + let service: PostService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(PostService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/services/post/post.service.ts b/apps/angular-app/src/app/services/post/post.service.ts new file mode 100644 index 00000000..b53575ae --- /dev/null +++ b/apps/angular-app/src/app/services/post/post.service.ts @@ -0,0 +1,118 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, map, take } from 'rxjs'; + +import { Comment, CommentResponse } from '../../models/Comment'; +import { GetPostResponse, GetPostsResponse, Post } from '../../models/Post'; + +@Injectable({ + providedIn: 'root' +}) +export class PostService { + private postsApiUrl = 'http://localhost:4200/api/posts'; + + constructor(private http: HttpClient) {} + + public getPosts(selectedCategory: string): Observable> { + const url = selectedCategory === 'all' ? this.postsApiUrl : `${this.postsApiUrl}/category/${selectedCategory}`; + + return this.http.get(url).pipe( + take(1), + map((posts) => + posts.map((p) => ({ + id: p._id, + title: p.title, + image: p.image, + description: p.description, + category: { + id: p.category._id, + name: p.category.name + }, + comments: { + count: p.comments.length + } + })) + ) + ); + } + + public getPost(postId: string): Observable { + return this.http.get(`${this.postsApiUrl}/${postId}`).pipe( + take(1), + map((post) => ({ + id: post._id, + title: post.title, + image: post.image, + description: post.description, + category: { + id: post.category._id, + name: post.category.name + }, + comments: { + count: post.comments.length, + data: post.comments.map((c) => ({ + id: c._id, + author: c.author, + content: c.content + })) + } + })) + ); + } + + public upsertPost(post: Partial): Observable { + const format = (post: GetPostResponse) => ({ + id: post._id, + title: post.title, + image: post.image, + description: post.description, + category: { + id: post.category._id, + name: post.category.name + }, + comments: { + count: post.comments.length, + data: post.comments.map((c) => ({ + id: c._id, + author: c.author, + content: c.content + })) + } + }); + + if (post.id) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, comments, ...payload } = post; + // Edit post + return this.http.patch(`${this.postsApiUrl}/${post.id}`, payload).pipe( + take(1), + map((post) => format(post)) + ); + } + + // Create post + return this.http.post(this.postsApiUrl, post).pipe( + take(1), + map((post) => format(post)) + ); + } + + public deletePost(postId: string): Observable { + return this.http.delete(`${this.postsApiUrl}/${postId}`).pipe(take(1)); + } + + public addComment(postId: string, comment: string): Observable { + const payload = { + author: 'Anonymous', + content: comment + }; + return this.http.post(`${this.postsApiUrl}/${postId}/comments`, payload).pipe( + take(1), + map((comment) => ({ + id: comment._id, + author: comment.author, + content: comment.content + })) + ); + } +} diff --git a/apps/angular-app/src/app/shared/url-validator.ts b/apps/angular-app/src/app/shared/url-validator.ts new file mode 100644 index 00000000..a9559e90 --- /dev/null +++ b/apps/angular-app/src/app/shared/url-validator.ts @@ -0,0 +1,10 @@ +import { AbstractControl } from '@angular/forms'; + +export function urlValidator(control: AbstractControl): { [key: string]: boolean } | null { + const url = control.value; + + const URL_REGEXP = + /^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/; + + return URL_REGEXP.test(url) ? null : { invalidUrl: true }; +} diff --git a/apps/angular-app/src/app/shared/variables.scss b/apps/angular-app/src/app/shared/variables.scss index e69de29b..02ef634a 100644 --- a/apps/angular-app/src/app/shared/variables.scss +++ b/apps/angular-app/src/app/shared/variables.scss @@ -0,0 +1,3 @@ +$color-blue: #3f3fc9; +$color-blue-light: #5b5bc0; +$color-gray: #ccc; diff --git a/apps/angular-app/src/app/views/.gitkeep b/apps/angular-app/src/app/views/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/angular-app/src/app/views/home/home.component.html b/apps/angular-app/src/app/views/home/home.component.html new file mode 100644 index 00000000..417747b1 --- /dev/null +++ b/apps/angular-app/src/app/views/home/home.component.html @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/angular-app/src/app/components/.gitkeep b/apps/angular-app/src/app/views/home/home.component.scss similarity index 100% rename from apps/angular-app/src/app/components/.gitkeep rename to apps/angular-app/src/app/views/home/home.component.scss diff --git a/apps/angular-app/src/app/views/home/home.component.spec.ts b/apps/angular-app/src/app/views/home/home.component.spec.ts new file mode 100644 index 00000000..5dd05d2c --- /dev/null +++ b/apps/angular-app/src/app/views/home/home.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HomeComponent } from './home.component'; + +describe('HomeComponent', () => { + let component: HomeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HomeComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(HomeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/views/home/home.component.ts b/apps/angular-app/src/app/views/home/home.component.ts new file mode 100644 index 00000000..1786d9a6 --- /dev/null +++ b/apps/angular-app/src/app/views/home/home.component.ts @@ -0,0 +1,69 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; + +import { BehaviorSubject, Observable, catchError, of, switchMap, take, tap, withLatestFrom } from 'rxjs'; +import { AddPostComponent } from '../../components/add-post/add-post.component'; +import { CategoriesComponent } from '../../components/categories/categories.component'; +import { PostGridComponent } from '../../components/post-grid/post-grid.component'; +import { PostModalComponent } from '../../components/post-modal/post-modal.component'; +import { PostModalService } from '../../components/post-modal/post-modal.service'; +import { Category } from '../../models/Category'; +import { Post } from '../../models/Post'; +import { CategoryService } from '../../services/category/category.service'; +import { PostService } from '../../services/post/post.service'; + +@Component({ + selector: 'mfee-home', + standalone: true, + imports: [CommonModule, AddPostComponent, CategoriesComponent, PostGridComponent, PostModalComponent], + templateUrl: './home.component.html', + styleUrl: './home.component.scss' +}) +export class HomeComponent implements OnInit { + public categories$: Observable; + public posts$: Observable; + + private selectedCategory = new BehaviorSubject('all'); + public selectedCategory$ = this.selectedCategory.asObservable(); + + constructor(private postService: PostService, private categoryService: CategoryService, private postModalService: PostModalService) {} + + ngOnInit(): void { + this.categories$ = this.categoryService.getCategories(); + + this.posts$ = this.selectedCategory$.pipe(switchMap((selectedCategory) => this.postService.getPosts(selectedCategory))); + } + + public onCategoryChange(categoryId: string): void { + this.selectedCategory.next(categoryId); + } + + public onAddPost(): void { + this.postModalService.setInfo('Create Post'); + this.postModalService.open(); + } + + public onEditPost(postId: string): void { + this.postService.getPost(postId).subscribe((post) => { + this.postModalService.setInfo('Edit Post', post); + this.postModalService.open(); + }); + } + + public onDeletePost(postId: string): void { + this.postService + .deletePost(postId) + .pipe( + take(1), + withLatestFrom(this.selectedCategory$), + tap(([, selectedCategory]) => { + this.onCategoryChange(selectedCategory); + }), + catchError(() => { + // TODO : Show an error message to the user + return of(null); + }) + ) + .subscribe(); + } +} diff --git a/apps/angular-app/src/app/views/page-not-found/page-not-found.component.html b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.html new file mode 100644 index 00000000..35fc2c27 --- /dev/null +++ b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.html @@ -0,0 +1 @@ +

Ups! Something went wrong, we couldn't find this page.

diff --git a/apps/angular-app/src/app/models/.gitkeep b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.scss similarity index 100% rename from apps/angular-app/src/app/models/.gitkeep rename to apps/angular-app/src/app/views/page-not-found/page-not-found.component.scss diff --git a/apps/angular-app/src/app/views/page-not-found/page-not-found.component.spec.ts b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.spec.ts new file mode 100644 index 00000000..0ec6519d --- /dev/null +++ b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PageNotFoundComponent } from './page-not-found.component'; + +describe('PageNotFoundComponent', () => { + let component: PageNotFoundComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PageNotFoundComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PageNotFoundComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/views/page-not-found/page-not-found.component.ts b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.ts new file mode 100644 index 00000000..bba9d8ff --- /dev/null +++ b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'mfee-page-not-found', + standalone: true, + imports: [CommonModule], + templateUrl: './page-not-found.component.html', + styleUrl: './page-not-found.component.scss', +}) +export class PageNotFoundComponent {} diff --git a/apps/angular-app/src/app/views/post/post.component.html b/apps/angular-app/src/app/views/post/post.component.html new file mode 100644 index 00000000..736ce009 --- /dev/null +++ b/apps/angular-app/src/app/views/post/post.component.html @@ -0,0 +1,43 @@ +
+
+ +

{{ post.title }}

+
+
+
+

{{ post.description }}

+ +
+

Comments

+ +
+ + + +
+
    +
  • +
    + person + {{ comment.author }} +
    +

    {{ comment.content }}

    +
  • +
+
+
+
+
\ No newline at end of file diff --git a/apps/angular-app/src/app/views/post/post.component.scss b/apps/angular-app/src/app/views/post/post.component.scss new file mode 100644 index 00000000..ddef47cb --- /dev/null +++ b/apps/angular-app/src/app/views/post/post.component.scss @@ -0,0 +1,107 @@ +.mfee-post { + &__header { + display: flex; + color: white; + min-height: 50vh; + position: relative; + align-items: center; + justify-content: center; + background-position: center top; + background-size: cover; + background-repeat: no-repeat; + } + + &__actions { + position: absolute; + top: 0; + left: 0; + padding: 10px 0 0 10px; + } + + &__action { + display: flex; + align-items: center; + text-decoration: none; + color: white; + + &:hover { + color: #ccc; + } + } + + &__title { + font-size: 2.25rem; + text-align: center; + } + + &__content-wrapper { + background-color: #ececec; + display: flex; + } + + &__content { + padding: 0 1rem; + margin: 0 auto; + flex-grow: 1; + + @media (min-width: 576px) { + max-width: 540px; + } + + @media (min-width: 768px) { + max-width: 720px; + } + + @media (min-width: 992px) { + max-width: 960px; + } + + @media (min-width: 1200px) { + max-width: 1140px; + } + } + + &__description { + white-space: pre-line; + } + + &-comments { + margin: 0 auto; + + @media (min-width: 768px) { + max-width: 480px; + } + } + + &__comment-wrapper { + padding: 0; + list-style: none; + } + + &__comment { + background-color: white; + box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.1); + margin-bottom: 8px; + padding: 8px; + + &-name { + display: flex; + align-items: center; + font-weight: bold; + margin-bottom: 8px; + } + + &-avatar { + background-color: black; + color: white; + border-radius: 99px; + padding: 2px; + margin-right: 8px; + } + + &-description { + margin: 0; + margin-left: 36px; + } + } +} diff --git a/apps/angular-app/src/app/views/post/post.component.spec.ts b/apps/angular-app/src/app/views/post/post.component.spec.ts new file mode 100644 index 00000000..15c9dacd --- /dev/null +++ b/apps/angular-app/src/app/views/post/post.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PostComponent } from './post.component'; + +describe('PostComponent', () => { + let component: PostComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PostComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/views/post/post.component.ts b/apps/angular-app/src/app/views/post/post.component.ts new file mode 100644 index 00000000..67622978 --- /dev/null +++ b/apps/angular-app/src/app/views/post/post.component.ts @@ -0,0 +1,41 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { Observable, switchMap } from 'rxjs'; + +import { Post } from '../../models/Post'; +import { PostService } from '../../services/post/post.service'; + +@Component({ + selector: 'mfee-post', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, RouterLink], + templateUrl: './post.component.html', + styleUrl: './post.component.scss' +}) +export class PostComponent implements OnInit { + public post$: Observable; + + commentForm = this.fb.group({ + comment: ['', Validators.required] + }); + + constructor(private route: ActivatedRoute, private fb: FormBuilder, private postService: PostService) {} + + ngOnInit(): void { + this.post$ = this.route.params.pipe(switchMap(({ id }) => this.postService.getPost(id))); + } + + onSubmit(postId: string) { + this.postService.addComment(postId, this.commentForm.value.comment).subscribe((comment) => { + // TODO : Add comment to UI + console.log(comment); + }); + this.commentForm.reset(); + } + + get comment() { + return this.commentForm.get('comment'); + } +} diff --git a/apps/angular-app/src/index.html b/apps/angular-app/src/index.html index da0603dd..f7be7978 100644 --- a/apps/angular-app/src/index.html +++ b/apps/angular-app/src/index.html @@ -7,6 +7,7 @@ + diff --git a/apps/angular-app/src/styles.scss b/apps/angular-app/src/styles.scss index cf488252..8884a5ec 100644 --- a/apps/angular-app/src/styles.scss +++ b/apps/angular-app/src/styles.scss @@ -4,3 +4,83 @@ body { font-family: 'Open Sans', sans-serif; } + +.mfee-button { + border: none; + background: $color-blue; + color: white; + font-size: 0.875rem; + padding: 0.5rem 1.5rem; + border-radius: 2px; + cursor: pointer; + + &:hover { + background-color: $color-blue-light; + } + + &--link { + background-color: transparent; + color: black; + + &:hover { + text-decoration: underline; + background-color: transparent; + } + } + + &:disabled { + color: $color-gray; + cursor: not-allowed; + } +} + +.mfee-form-control { + display: flex; + flex-direction: column; + margin-bottom: 1rem; + font-size: 0.75rem; + position: relative; + + &__input { + border: none; + border-bottom: 1px solid $color-gray; + outline: none !important; + margin-top: 2px; + padding: 0.25rem 0; + resize: none; + background-color: transparent; + + &:active, + &:focus { + border-bottom-color: $color-blue; + } + + &.ng-dirty.ng-invalid, + &.ng-touched.ng-invalid { + border-bottom-color: red; + } + } + + &__error { + position: absolute; + bottom: 0; + right: 0; + color: red; + } +} + +::-webkit-scrollbar { + width: 6px; + background-color: #f5f5f5; +} + +::-webkit-scrollbar-thumb { + border-radius: 10px; + -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); + background-color: #555; +} + +::-webkit-scrollbar-track { + -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); + background-color: #f5f5f5; +} diff --git a/apps/api/.env b/apps/api/.env index ecaf5a7c..c2651885 100644 --- a/apps/api/.env +++ b/apps/api/.env @@ -1,3 +1,3 @@ ACCESS_TOKEN_SECRET=[YOUR_ACCESS_TOKEN_SECRET_HERE] REFRESH_TOKEN_SECRET=[YOUR_REFRESH_TOKEN_SECRET_HERE] -MONGO_URL=[YOUR_MONGO_CONNECTION_STRING_HERE] \ No newline at end of file +MONGO_URL=mongodb+srv://david:Cn5t$.@cluster0.btfyy.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0 \ No newline at end of file diff --git a/apps/api/README.md b/apps/api/README.md index 6066cc0e..658178b6 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -21,7 +21,52 @@ ## Challenges -### Session * +### Session 01 + +- Create `route` for `posts` endpoint with the following methods: + - `GET /posts` Return an array of all the posts with status code 200 + - `GET /posts/category/:category` Return an array of all the posts by category with status code 200 + - `GET /posts/:id` Return a post by id with category object and each comment object in the array with status code 200 + - `POST /posts` Create a new post and return the created post with status code 201 + - `POST /posts/:id/comments` Create a comment inside the post and return the comment with status code 201 + - `PATCH /posts/:id` Update post information and return the updated post with status code 200 + - `DELETE /posts/:id` Delete the post and return the deleted post with status code 200 or 204 if you decide to not return anything + * *Add 404 validation where needed* + +- Post model + - id: string + - title: string + - image: string + - description: string + - category: string *Id of the category* + - comments: array *Array of comment ids* + +- Comment model + - id: string + - author: string + - content: string + +### Session 02 + +- Refactor the code from last session to add a post controller + +### Session 03 + +- N/A + +### Session 04 + +- N/A + +### Session 05 + +- Create MongoDB database +- Connect to MongoDB database using mongoose +- Create models for Post and Comment +- Refactor the controller to retrieve information from database + - *Tip: Use `populate` method to get data from reference id* +- **Extra** + - Remove post comments from database when you delete the post ## How to diff --git a/apps/api/src/config/.gitkeep b/apps/api/src/config/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/config/corsConfig.ts b/apps/api/src/config/corsConfig.ts new file mode 100644 index 00000000..045cf517 --- /dev/null +++ b/apps/api/src/config/corsConfig.ts @@ -0,0 +1,15 @@ +const allowedOrigins = ['http://localhost:4200', 'http://localhost:3000']; + +export const corsOptions = { + origin: (origin, callback) => { + // Note: origin will be undefined from same route in local development + if (allowedOrigins.indexOf(origin) !== -1 || !origin) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + optionsSuccessStatus: 200 +}; + +export default { corsOptions }; diff --git a/apps/api/src/controllers/.gitkeep b/apps/api/src/controllers/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/controllers/auth.ts b/apps/api/src/controllers/auth.ts new file mode 100644 index 00000000..96bb74b5 --- /dev/null +++ b/apps/api/src/controllers/auth.ts @@ -0,0 +1,97 @@ +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; + +import { User } from '../models/user'; + +const users: User[] = []; + +const register = async (req, res) => { + const { username, password } = req.body; + + // Check that we have the correct payload + if (!username || !password) { + return res.status(400).json({ + message: 'Username and password are required' + }); + } + + // Check that we don't have duplicates + const duplicate = users.find((u) => u.username === username); + if (duplicate) { + return res.status(409).json({ message: 'User already exist' }); + } + + try { + // Encrypt the password + const hashedPassword = await bcrypt.hash(password, 10); + + // Store new user + users.push({ username, password: hashedPassword }); + + res.status(201).json({ message: 'User registered successfully' }); + } catch (e) { + res.status(500).json({ message: e.message }); + } +}; + +const login = async (req, res) => { + const { username, password } = req.body; + + // Check that we have the correct payload + if (!username || !password) { + return res.status(400).json({ + message: 'Username and password are required' + }); + } + + // Retrieve user + const user = users.find((u) => u.username === username); + + // Check if we found the user and the password matches + if (!user || !(await bcrypt.compare(password, user.password))) { + return res.status(401).json({ message: 'Invalid credentials' }); + } + + // Generate access token and refresh token + const accessToken = jwt.sign({ username }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' }); + const refreshToken = jwt.sign({ username }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: '7d' }); + + // Save refresh token + res.cookie('refreshToken', refreshToken, { + httpOnly: true, + maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days in milliseconds + }); + + res.json({ accessToken }); +}; + +const refresh = (req, res) => { + // Get refresh token from cookies + const refreshToken = req.cookies.refreshToken; + + if (!refreshToken) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, { username }) => { + if (err) { + // Invalid token + return res.status(403).json({ message: 'Forbidden' }); + } + + const accessToken = jwt.sign({ username }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' }); + res.json({ accessToken }); + }); +}; + +const logout = (req, res) => { + res.clearCookie('refreshToken'); + res.json({ message: 'Logged out successfully' }); +}; + +export default { + register, + login, + refresh, + logout +}; diff --git a/apps/api/src/controllers/category.ts b/apps/api/src/controllers/category.ts new file mode 100644 index 00000000..cf881549 --- /dev/null +++ b/apps/api/src/controllers/category.ts @@ -0,0 +1,101 @@ +import Category from '../models/category'; + +// Get all categories +const getCategories = async (req, res) => { + try { + const categories = await Category.find(); + // Return all the categories with a 200 status code + res.status(200).json(categories); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Get category by id +const getCategoryById = async (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + + try { + // Check if we have a category with that id + const category = await Category.findById(id); + + if (!category) { + // If we don't find the category return a 404 status code with a message + return res.status(404).json({ message: 'Category not found' }); + // Note: Remember that json method doesn't interrupt the workflow + // therefore is important to add a "return" to break the process + } + + // Return the category with a 200 status code + res.status(200).json(category); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Create category +const createCategory = async (req, res) => { + try { + const category = await Category.create(req.body); + // Return the created category with a 201 status code + res.status(201).json(category); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Update category +const updateCategory = async (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + + try { + // Check and update if we have a category with that id + const category = await Category.findByIdAndUpdate(id, req.body, { new: true }); + + // If we don't find the category return a 404 status code with a message + if (!category) { + return res.status(404).json({ message: 'Category not found' }); + } + + // Return the updated category with a 200 status code + res.status(200).json(category); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Delete category +const deleteCategory = async (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + + try { + // Check and delete if we have a category with that id + const category = await Category.findByIdAndDelete(id); + + // If we don't find the category return a 404 status code with a message + if (!category) { + return res.status(404).json({ message: 'Category not found' }); + } + + // Return a 200 status code + res.status(200).json(category); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +export default { + getCategories, + getCategoryById, + createCategory, + updateCategory, + deleteCategory +}; diff --git a/apps/api/src/controllers/post.ts b/apps/api/src/controllers/post.ts new file mode 100644 index 00000000..78217311 --- /dev/null +++ b/apps/api/src/controllers/post.ts @@ -0,0 +1,161 @@ +import Post from '../models/post'; +import Comment from '../models/comment'; + + + +// Get all posts +const getPosts = async (req, res) => { + try { + const posts = await Post.find().populate('category').populate('comments').exec(); + res.status(200).json(posts); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Get post by id +const getPostById = async (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + + try { + // Check if we have a post with that id + const post = await Post.findById(id).populate('category').populate('comments').exec(); + + if (!post) { + // If we don't find the post return a 404 status code with a message + return res.status(404).json({ message: 'Post not found' }); + // Note: Remember that json method doesn't interrupt the workflow + // therefore is important to add a "return" to break the process + } + + // Return the post with a 200 status code + res.status(200).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Create post +const createPost = async (req, res) => { + try { + const post = await Post.create(req.body); + // Return the created post with a 201 status code + res.status(201).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Update post +const updatePost = async (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + + try { + // Check and update if we have a post with that id + const post = await Post.findByIdAndUpdate(id, req.body, { new: true }); + + // If we don't find the post return a 404 status code with a message + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + + // Return the updated post with a 200 status code + res.status(200).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Delete post +const deletePost = async (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + + try { + // Check and delete if we have a post with that id + const post = await Post.findByIdAndDelete(id); + + // If we don't find the post return a 404 status code with a message + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + + await Comment.deleteMany({ _id: { $in: post.comments } }); + + // Return a 200 status code + res.status(200).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Get post by category +const getPostsByCategory = async (req, res) => { + // Retrieve the category from the route params + const { category } = req.params; + + try { + // Check if we have a post with that id + const post = await Post.find({ category }).populate('category').populate('comments').exec(); + + if (!post) { + // If we don't find the post return a 404 status code with a message + return res.status(404).json({ message: 'Posts not found' }); + // Note: Remember that json method doesn't interrupt the workflow + // therefore is important to add a "return" to break the process + } + + // Return the post with a 200 status code + res.status(200).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Create post comment +const createPostComment = async (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + + try { + // Check if we have a post with that id + const post = await Post.findById(id).populate('category').populate('comments').exec(); + + if (!post) { + // If we don't find the post return a 404 status code with a message + return res.status(404).json({ message: 'Post not found' }); + // Note: Remember that json method doesn't interrupt the workflow + // therefore is important to add a "return" to break the process + } + + const newComment = await Comment.create(req.body); + + post.comments.push(newComment.id); + await post.save(); + + res.status(201).json(newComment); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + + + +export default { + getPosts, + getPostById, + createPost, + updatePost, + deletePost, + getPostsByCategory, + createPostComment +}; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index e5fad103..96888e1e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,14 +1,41 @@ +import cors from 'cors'; import express from 'express'; +import helmet from 'helmet'; +import mongoose from 'mongoose'; + +import { corsOptions } from './config/corsConfig'; +import { verifyToken } from './middleware/auth'; +import { errorHandler } from './middleware/errorHandler'; +import auth from './routes/auth'; +import categories from './routes/categories'; +import posts from './routes/posts'; const host = process.env.HOST ?? 'localhost'; const port = process.env.PORT ? Number(process.env.PORT) : 3000; const app = express(); -app.get('/', (req, res) => { - res.send({ message: 'Hello MFEE!' }); -}); +app.use(express.json()); +app.use(helmet()); +app.use(cors(corsOptions)); + +app.use('/api/auth', auth); + +app.use(verifyToken); +app.use('/api/categories', categories); +app.use('/api/posts', posts); + +app.use(errorHandler); + +mongoose + .connect(process.env.MONGO_URL) + .then(() => { + console.log('Connected to MongoDB'); -app.listen(port, host, () => { - console.log(`[ ready ] http://${host}:${port}`); -}); + app.listen(port, host, () => { + console.log(`[ ready ] http://${host}:${port}`); + }); + }) + .catch((e) => { + console.error(e); + }); diff --git a/apps/api/src/middleware/.gitkeep b/apps/api/src/middleware/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts new file mode 100644 index 00000000..193a5a9e --- /dev/null +++ b/apps/api/src/middleware/auth.ts @@ -0,0 +1,24 @@ +import jwt from 'jsonwebtoken'; + +export const verifyToken = (req, res, next) => { + const authHeader = req.headers['authorization']; + + if (!authHeader) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const token = authHeader.split(' ')[1]; + jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => { + if (err) { + // Invalid token + return res.status(403).json({ message: 'Forbidden' }); + } + + req.user = user; + next(); + }); +}; + +export default { + verifyToken +}; diff --git a/apps/api/src/middleware/errorHandler.ts b/apps/api/src/middleware/errorHandler.ts new file mode 100644 index 00000000..90969d56 --- /dev/null +++ b/apps/api/src/middleware/errorHandler.ts @@ -0,0 +1,9 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const errorHandler = (err, req, res, next) => { + // Log this for debug purposes + console.error(err.stack); + // Return custom error to user + res.status(500).json({ error: 'Internal Server Error' }); +}; + +export default { errorHandler }; diff --git a/apps/api/src/models/.gitkeep b/apps/api/src/models/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/models/category.ts b/apps/api/src/models/category.ts new file mode 100644 index 00000000..b65f2216 --- /dev/null +++ b/apps/api/src/models/category.ts @@ -0,0 +1,21 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +interface ICategory extends Document { + name: string; +} + +const categorySchema = new Schema( + { + name: { + type: String, + required: [true, 'Property is required'] + } + }, + { + timestamps: true + } +); + +const Category = mongoose.model('Category', categorySchema); + +export default Category; diff --git a/apps/api/src/models/comment.ts b/apps/api/src/models/comment.ts new file mode 100644 index 00000000..57d88f6a --- /dev/null +++ b/apps/api/src/models/comment.ts @@ -0,0 +1,26 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +interface IComment extends Document { + author: string; + content: string; +} + +const commentSchema = new Schema( + { + author: { + type: String, + required: [true, 'Property is required'] + }, + content: { + type: String, + required: [true, 'Property is required'] + } + }, + { + timestamps: true + } +); + +const Comment = mongoose.model('Comment', commentSchema); + +export default Comment; diff --git a/apps/api/src/models/post.ts b/apps/api/src/models/post.ts new file mode 100644 index 00000000..63d34f1e --- /dev/null +++ b/apps/api/src/models/post.ts @@ -0,0 +1,45 @@ +import mongoose, { Document, Schema } from 'mongoose'; + + +interface IPost extends Document { + title: string; + image: string; + description: string; + category: mongoose.Types.ObjectId; + comments: mongoose.Types.ObjectId[]; +} + +const postSchema = new Schema( + { + title: { + type: String, + required: [true, 'Property is required'] + }, + image: { + type: String, + required: [true, 'Property is required'] + }, + description: { + type: String, + required: [true, 'Property is required'] + }, + category: { + type: Schema.Types.ObjectId, + ref: 'Category', + required: [true, 'Property is required'] + }, + comments: [ + { + type: Schema.Types.ObjectId, + ref: 'Comment' + } + ] + }, + { + timestamps: true + } +); + +const Post = mongoose.model('Post', postSchema); + +export default Post; diff --git a/apps/api/src/models/user.ts b/apps/api/src/models/user.ts new file mode 100644 index 00000000..6ac7896e --- /dev/null +++ b/apps/api/src/models/user.ts @@ -0,0 +1,4 @@ +export type User = { + username: string; + password: string; +}; diff --git a/apps/api/src/routes/.gitkeep b/apps/api/src/routes/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 00000000..062e9bc9 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,15 @@ +import express from 'express'; + +import authController from '../controllers/auth'; + +const router = express.Router(); + +router.post('/register', authController.register); + +router.post('/login', authController.login); + +router.post('/refresh', authController.refresh); + +router.post('/logout', authController.logout); + +export default router; diff --git a/apps/api/src/routes/categories.ts b/apps/api/src/routes/categories.ts new file mode 100644 index 00000000..78e120a1 --- /dev/null +++ b/apps/api/src/routes/categories.ts @@ -0,0 +1,22 @@ +import express from 'express'; + +import categoryController from '../controllers/category'; + +const router = express.Router(); + +// Get all categories +router.get('/', categoryController.getCategories); + +// Get category by id +router.get('/:id', categoryController.getCategoryById); + +// Create category +router.post('/', categoryController.createCategory); + +// Update category +router.patch('/:id', categoryController.updateCategory); + +// Delete category +router.delete('/:id', categoryController.deleteCategory); + +export default router; diff --git a/apps/api/src/routes/posts.ts b/apps/api/src/routes/posts.ts new file mode 100644 index 00000000..519fcb87 --- /dev/null +++ b/apps/api/src/routes/posts.ts @@ -0,0 +1,28 @@ +import express from 'express'; + +import postController from '../controllers/post'; + +const router = express.Router(); + +// Get all posts +router.get('/', postController.getPosts); + +// Get post by id +router.get('/:id', postController.getPostById); + +// Create post +router.post('/', postController.createPost); + +// Update post +router.patch('/:id', postController.updatePost); + +// Delete post +router.delete('/:id', postController.deletePost); + +// Get all posts by category +router.get('/category/:category', postController.getPostsByCategory); + +// Create post comment +router.post('/:id/comments', postController.createPostComment); + +export default router;