diff --git a/.adonisjs/api.ts b/.adonisjs/api.ts index 9c3a99e..bebfcdc 100644 --- a/.adonisjs/api.ts +++ b/.adonisjs/api.ts @@ -31,14 +31,14 @@ type AuthLogoutGetHead = { type DashboardGetHead = { request: unknown; response: MakeTuyauResponse< - import('../app/collections/controllers/collections_controller.ts').default['index'], + import('../app/collections/controllers/show_collections_controller.ts').default['render'], false >; }; type CollectionsCreateGetHead = { request: unknown; response: MakeTuyauResponse< - import('../app/collections/controllers/collections_controller.ts').default['showCreatePage'], + import('../app/collections/controllers/create_collection_controller.ts').default['render'], false >; }; @@ -49,14 +49,14 @@ type CollectionsPost = { > >; response: MakeTuyauResponse< - import('../app/collections/controllers/collections_controller.ts').default['store'], + import('../app/collections/controllers/create_collection_controller.ts').default['execute'], true >; }; type CollectionsEditGetHead = { request: unknown; response: MakeTuyauResponse< - import('../app/collections/controllers/collections_controller.ts').default['showEditPage'], + import('../app/collections/controllers/update_collection_controller.ts').default['render'], false >; }; @@ -67,14 +67,14 @@ type CollectionsIdPut = { > >; response: MakeTuyauResponse< - import('../app/collections/controllers/collections_controller.ts').default['update'], + import('../app/collections/controllers/update_collection_controller.ts').default['execute'], true >; }; type CollectionsDeleteGetHead = { request: unknown; response: MakeTuyauResponse< - import('../app/collections/controllers/collections_controller.ts').default['showDeletePage'], + import('../app/collections/controllers/delete_collection_controller.ts').default['render'], false >; }; @@ -85,7 +85,7 @@ type CollectionsIdDelete = { > >; response: MakeTuyauResponse< - import('../app/collections/controllers/collections_controller.ts').default['delete'], + import('../app/collections/controllers/delete_collection_controller.ts').default['execute'], true >; }; diff --git a/.env.example b/.env.example index 1263b80..e5af7f1 100644 --- a/.env.example +++ b/.env.example @@ -8,9 +8,9 @@ NODE_ENV=development SESSION_DRIVER=cookie DB_HOST=127.0.0.1 DB_PORT=5432 -DB_USER=postgres -DB_PASSWORD=my-links-pwd -DB_DATABASE=my-links +DB_USER=root +DB_PASSWORD=root +DB_DATABASE=app GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_CALLBACK_URL=http://localhost:3333/auth/callback diff --git a/app/admin/controllers/admin_controller.ts b/app/admin/controllers/admin_controller.ts index d099a0e..6e24e0a 100644 --- a/app/admin/controllers/admin_controller.ts +++ b/app/admin/controllers/admin_controller.ts @@ -1,5 +1,5 @@ import AuthController from '#auth/controllers/auth_controller'; -import CollectionsController from '#collections/controllers/collections_controller'; +import CollectionsController from '#collections/controllers/show_collections_controller'; import LinksController from '#links/controllers/links_controller'; import User from '#user/models/user'; import { inject } from '@adonisjs/core'; diff --git a/app/collections/controllers/base_collection_controller.ts b/app/collections/controllers/base_collection_controller.ts new file mode 100644 index 0000000..297e189 --- /dev/null +++ b/app/collections/controllers/base_collection_controller.ts @@ -0,0 +1,32 @@ +import Collection from '#collections/models/collection'; +import { HttpContext } from '@adonisjs/core/http'; +import vine from '@vinejs/vine'; + +export default class BaseCollectionController { + protected collectionIdValidator = vine.compile( + vine.object({ + collectionId: vine.number().positive().optional(), + }) + ); + + async validateCollectionId(collectionIdRequired: boolean = true) { + const ctx = HttpContext.getOrFail(); + const { collectionId } = await ctx.request.validateUsing( + this.collectionIdValidator + ); + if (!collectionId && collectionIdRequired) { + console.log('redirecting to dashboard'); + ctx.response.redirectToNamedRoute('dashboard'); + return null; + } + console.log('collectionId', collectionId); + return collectionId; + } + + redirectToCollectionId(collectionId: Collection['id']) { + const ctx = HttpContext.getOrFail(); + return ctx.response.redirectToNamedRoute('dashboard', { + qs: { collectionId }, + }); + } +} diff --git a/app/collections/controllers/collections_controller.ts b/app/collections/controllers/collections_controller.ts deleted file mode 100644 index 992eb1d..0000000 --- a/app/collections/controllers/collections_controller.ts +++ /dev/null @@ -1,141 +0,0 @@ -import Collection from '#collections/models/collection'; -import { createCollectionValidator } from '#collections/validators/create_collection_validator'; -import { deleteCollectionValidator } from '#collections/validators/delete_collection_validator'; -import { updateCollectionValidator } from '#collections/validators/update_collection_validator'; -import User from '#user/models/user'; -import type { HttpContext } from '@adonisjs/core/http'; -import db from '@adonisjs/lucid/services/db'; - -export default class CollectionsController { - // Dashboard - async index({ auth, inertia, request, response }: HttpContext) { - const collections = await this.getCollectionsByAuthorId(auth.user!.id); - if (collections.length === 0) { - return response.redirectToNamedRoute('collection.create-form'); - } - - const activeCollectionId = Number(request.qs()?.collectionId ?? ''); - const activeCollection = collections.find( - (c) => c.id === activeCollectionId - ); - - if (!activeCollection && !!activeCollectionId) { - return response.redirectToNamedRoute('dashboard'); - } - - // TODO: Create DTOs - return inertia.render('dashboard', { - collections: collections.map((collection) => collection.serialize()), - activeCollection: - activeCollection?.serialize() || collections[0].serialize(), - }); - } - - // Create collection form - async showCreatePage({ inertia, auth }: HttpContext) { - const collections = await this.getCollectionsByAuthorId(auth.user!.id); - return inertia.render('collections/create', { - disableHomeLink: collections.length === 0, - }); - } - - // Method called when creating a collection - async store({ request, response, auth }: HttpContext) { - const payload = await request.validateUsing(createCollectionValidator); - const collection = await Collection.create({ - ...payload, - authorId: auth.user?.id!, - }); - return this.redirectToCollectionId(response, collection.id); - } - - async showEditPage({ auth, request, inertia, response }: HttpContext) { - const collectionId = request.qs()?.collectionId; - if (!collectionId) { - return response.redirectToNamedRoute('dashboard'); - } - - const collection = await this.getCollectionById( - collectionId, - auth.user!.id - ); - return inertia.render('collections/edit', { - collection, - }); - } - - async update({ request, auth, response }: HttpContext) { - const { params, ...payload } = await request.validateUsing( - updateCollectionValidator - ); - - // Cant use validator (vinejs) custom rule 'cause its too generic, - // because we have to find a collection by identifier and - // check whether the current user is the author. - // https://vinejs.dev/docs/extend/custom_rules - await this.getCollectionById(params.id, auth.user!.id); - - await Collection.updateOrCreate( - { - id: params.id, - }, - payload - ); - return this.redirectToCollectionId(response, params.id); - } - - async showDeletePage({ auth, request, inertia, response }: HttpContext) { - const collectionId = request.qs()?.collectionId; - if (!collectionId) { - return response.redirectToNamedRoute('dashboard'); - } - - const collection = await this.getCollectionById( - collectionId, - auth.user!.id - ); - return inertia.render('collections/delete', { - collection, - }); - } - - async delete({ request, auth, response }: HttpContext) { - const { params } = await request.validateUsing(deleteCollectionValidator); - const collection = await this.getCollectionById(params.id, auth.user!.id); - await collection.delete(); - return response.redirectToNamedRoute('dashboard'); - } - - async getTotalCollectionsCount() { - const totalCount = await db.from('collections').count('* as total'); - return Number(totalCount[0].total); - } - - /** - * Get collection by id. - * - * /!\ Only return private collection (create by the current user) - */ - async getCollectionById(id: Collection['id'], userId: User['id']) { - return await Collection.query() - .where('id', id) - .andWhere('author_id', userId) - .firstOrFail(); - } - - async getCollectionsByAuthorId(authorId: User['id']) { - return await Collection.query() - .where('author_id', authorId) - .orderBy('created_at') - .preload('links'); - } - - redirectToCollectionId( - response: HttpContext['response'], - collectionId: Collection['id'] - ) { - return response.redirectToNamedRoute('dashboard', { - qs: { collectionId }, - }); - } -} diff --git a/app/collections/controllers/create_collection_controller.ts b/app/collections/controllers/create_collection_controller.ts new file mode 100644 index 0000000..d993006 --- /dev/null +++ b/app/collections/controllers/create_collection_controller.ts @@ -0,0 +1,25 @@ +import BaseCollectionController from '#collections/controllers/base_collection_controller'; +import { CollectionService } from '#collections/services/collection_service'; +import { createCollectionValidator } from '#collections/validators/create_collection_validator'; +import { inject } from '@adonisjs/core'; +import { type HttpContext } from '@adonisjs/core/http'; + +@inject() +export default class CreateCollectionController extends BaseCollectionController { + constructor(private collectionService: CollectionService) { + super(); + } + + async render({ inertia }: HttpContext) { + const collections = await this.collectionService.getCollectionsByAuthorId(); + return inertia.render('collections/create', { + disableHomeLink: collections.length === 0, + }); + } + + async execute({ request }: HttpContext) { + const payload = await request.validateUsing(createCollectionValidator); + const collection = await this.collectionService.createCollection(payload); + return this.redirectToCollectionId(collection.id); + } +} diff --git a/app/collections/controllers/delete_collection_controller.ts b/app/collections/controllers/delete_collection_controller.ts new file mode 100644 index 0000000..4b35766 --- /dev/null +++ b/app/collections/controllers/delete_collection_controller.ts @@ -0,0 +1,29 @@ +import BaseCollectionController from '#collections/controllers/base_collection_controller'; +import { CollectionService } from '#collections/services/collection_service'; +import { deleteCollectionValidator } from '#collections/validators/delete_collection_validator'; +import { inject } from '@adonisjs/core'; +import { HttpContext } from '@adonisjs/core/http'; + +@inject() +export default class DeleteCollectionController extends BaseCollectionController { + constructor(private collectionService: CollectionService) { + super(); + } + + async render({ inertia }: HttpContext) { + const collectionId = await this.validateCollectionId(); + if (!collectionId) return; + + const collection = + await this.collectionService.getCollectionById(collectionId); + return inertia.render('collections/delete', { + collection, + }); + } + + async execute({ request, response }: HttpContext) { + const { params } = await request.validateUsing(deleteCollectionValidator); + await this.collectionService.deleteCollection(params.id); + return response.redirectToNamedRoute('dashboard'); + } +} diff --git a/app/collections/controllers/show_collections_controller.ts b/app/collections/controllers/show_collections_controller.ts new file mode 100644 index 0000000..cbeb5a6 --- /dev/null +++ b/app/collections/controllers/show_collections_controller.ts @@ -0,0 +1,34 @@ +import BaseCollectionController from '#collections/controllers/base_collection_controller'; +import { CollectionService } from '#collections/services/collection_service'; +import { inject } from '@adonisjs/core'; +import type { HttpContext } from '@adonisjs/core/http'; + +@inject() +export default class ShowCollectionsController extends BaseCollectionController { + constructor(private collectionService: CollectionService) { + super(); + } + + // Dashboard + async render({ inertia, response }: HttpContext) { + const activeCollectionId = await this.validateCollectionId(false); + const collections = await this.collectionService.getCollectionsByAuthorId(); + if (collections.length === 0) { + return response.redirectToNamedRoute('collection.create-form'); + } + + const activeCollection = collections.find( + (c) => c.id === activeCollectionId + ); + + if (!activeCollection && !!activeCollectionId) { + return response.redirectToNamedRoute('dashboard'); + } + + return inertia.render('dashboard', { + collections: collections.map((collection) => collection.serialize()), + activeCollection: + activeCollection?.serialize() || collections[0].serialize(), + }); + } +} diff --git a/app/collections/controllers/update_collection_controller.ts b/app/collections/controllers/update_collection_controller.ts new file mode 100644 index 0000000..09fe854 --- /dev/null +++ b/app/collections/controllers/update_collection_controller.ts @@ -0,0 +1,33 @@ +import BaseCollectionController from '#collections/controllers/base_collection_controller'; +import { CollectionService } from '#collections/services/collection_service'; +import { updateCollectionValidator } from '#collections/validators/update_collection_validator'; +import { inject } from '@adonisjs/core'; +import { HttpContext } from '@adonisjs/core/http'; + +@inject() +export default class UpdateCollectionController extends BaseCollectionController { + constructor(private collectionService: CollectionService) { + super(); + } + + async render({ inertia }: HttpContext) { + const collectionId = await this.validateCollectionId(); + if (!collectionId) return; + + const collection = + await this.collectionService.getCollectionById(collectionId); + return inertia.render('collections/update', { + collection: collection.serialize(), + }); + } + + async execute({ request }: HttpContext) { + const { + params: { id: collectionId }, + ...payload + } = await request.validateUsing(updateCollectionValidator); + + await this.collectionService.updateCollection(collectionId, payload); + return this.redirectToCollectionId(collectionId); + } +} diff --git a/app/collections/routes/collections_routes.ts b/app/collections/routes/collections_routes.ts index 55331ed..8b242c4 100644 --- a/app/collections/routes/collections_routes.ts +++ b/app/collections/routes/collections_routes.ts @@ -1,33 +1,45 @@ import { middleware } from '#start/kernel'; import router from '@adonisjs/core/services/router'; -const CollectionsController = () => - import('#collections/controllers/collections_controller'); + +const ShowCollectionsController = () => + import('#collections/controllers/show_collections_controller'); +const CreateCollectionController = () => + import('#collections/controllers/create_collection_controller'); +const UpdateCollectionController = () => + import('#collections/controllers/update_collection_controller'); +const DeleteCollectionController = () => + import('#collections/controllers/delete_collection_controller'); router .group(() => { - router.get('/dashboard', [CollectionsController, 'index']).as('dashboard'); + router + .get('/dashboard', [ShowCollectionsController, 'render']) + .as('dashboard'); router .group(() => { + // Create router - .get('/create', [CollectionsController, 'showCreatePage']) + .get('/create', [CreateCollectionController, 'render']) .as('collection.create-form'); router - .post('/', [CollectionsController, 'store']) + .post('/', [CreateCollectionController, 'execute']) .as('collection.create'); + // Update router - .get('/edit', [CollectionsController, 'showEditPage']) + .get('/edit', [UpdateCollectionController, 'render']) .as('collection.edit-form'); router - .put('/:id', [CollectionsController, 'update']) + .put('/:id', [UpdateCollectionController, 'execute']) .as('collection.edit'); + // Delete router - .get('/delete', [CollectionsController, 'showDeletePage']) + .get('/delete', [DeleteCollectionController, 'render']) .as('collection.delete-form'); router - .delete('/:id', [CollectionsController, 'delete']) + .delete('/:id', [DeleteCollectionController, 'execute']) .as('collection.delete'); }) .prefix('/collections'); diff --git a/app/collections/services/collection_service.ts b/app/collections/services/collection_service.ts new file mode 100644 index 0000000..01f0805 --- /dev/null +++ b/app/collections/services/collection_service.ts @@ -0,0 +1,70 @@ +import { Visibility } from '#collections/enums/visibility'; +import Collection from '#collections/models/collection'; +import { HttpContext } from '@adonisjs/core/http'; +import db from '@adonisjs/lucid/services/db'; + +type CreateCollectionPayload = { + name: string; + description: string | null; + visibility: Visibility; +}; + +type UpdateCollectionPayload = CreateCollectionPayload; + +export class CollectionService { + async getCollectionById(id: Collection['id']) { + const context = this.getAuthContext(); + return await Collection.query() + .where('id', id) + .andWhere('author_id', context.auth.user!.id) + .firstOrFail(); + } + + async getCollectionsByAuthorId() { + const context = this.getAuthContext(); + return await Collection.query() + .where('author_id', context.auth.user!.id) + .orderBy('created_at') + .preload('links'); + } + + async getTotalCollectionsCount() { + const totalCount = await db.from('collections').count('* as total'); + return Number(totalCount[0].total); + } + + createCollection(payload: CreateCollectionPayload) { + const context = this.getAuthContext(); + return Collection.create({ + ...payload, + authorId: context.auth.user!.id, + }); + } + + async updateCollection( + id: Collection['id'], + payload: UpdateCollectionPayload + ) { + const context = this.getAuthContext(); + return await Collection.query() + .where('id', id) + .andWhere('author_id', context.auth.user!.id) + .update(payload); + } + + deleteCollection(id: Collection['id']) { + const context = this.getAuthContext(); + return Collection.query() + .where('id', id) + .andWhere('author_id', context.auth.user!.id) + .delete(); + } + + getAuthContext() { + const context = HttpContext.getOrFail(); + if (!context.auth.user || !context.auth.user.id) { + throw new Error('User not authenticated'); + } + return context; + } +} diff --git a/app/links/controllers/links_controller.ts b/app/links/controllers/links_controller.ts index 4a64234..645554a 100644 --- a/app/links/controllers/links_controller.ts +++ b/app/links/controllers/links_controller.ts @@ -1,4 +1,4 @@ -import CollectionsController from '#collections/controllers/collections_controller'; +import CollectionsController from '#collections/controllers/show_collections_controller'; import Link from '#links/models/link'; import { createLinkValidator } from '#links/validators/create_link_validator'; import { deleteLinkValidator } from '#links/validators/delete_link_validator'; diff --git a/config/app.ts b/config/app.ts index aa30afc..a3b2db7 100644 --- a/config/app.ts +++ b/config/app.ts @@ -1,7 +1,7 @@ import env from '#start/env'; -import app from '@adonisjs/core/services/app'; import { Secret } from '@adonisjs/core/helpers'; import { defineConfig } from '@adonisjs/core/http'; +import app from '@adonisjs/core/services/app'; /** * The app key is used for encrypting cookies, generating signed URLs, @@ -23,7 +23,7 @@ export const http = defineConfig({ * Enabling async local storage will let you access HTTP context * from anywhere inside your application. */ - useAsyncLocalStorage: false, + useAsyncLocalStorage: true, /** * Manage cookies configuration. The settings for the session id cookie are diff --git a/inertia/hooks/use_route.tsx b/inertia/hooks/use_route.tsx new file mode 100644 index 0000000..17b0bba --- /dev/null +++ b/inertia/hooks/use_route.tsx @@ -0,0 +1,48 @@ +import { ApiRouteName } from '#shared/types/index'; +import { useTuyau } from '@tuyau/inertia/react'; +import { buildUrl } from '~/lib/navigation'; + +interface TuyauRoute { + route: ApiRouteName; + params?: Record; // TODO: add type +} + +interface NonTuyauRoute { + href: string; +} + +type UseRouteProps = TuyauRoute | NonTuyauRoute; + +type TuyauReturn = { + url: string; + method: string; +}; + +type NonTuyauReturn = { + url: string; +}; + +type UseRouteReturn = T extends TuyauRoute + ? TuyauReturn + : NonTuyauReturn; + +export const useRoute = ( + props: T +): UseRouteReturn => { + const tuyau = useTuyau(); + if ('href' in props) { + return { + url: props.href, + } as UseRouteReturn; + } + + const route = tuyau?.$route(props.route, props.params); + if (!route) { + throw new Error(`Route ${props.route} not found`); + } + + return { + url: buildUrl(route.path, props.params ?? {}), + method: route.method, + } as UseRouteReturn; +}; diff --git a/inertia/lib/navigation.ts b/inertia/lib/navigation.ts index e8d881e..c2dd396 100644 --- a/inertia/lib/navigation.ts +++ b/inertia/lib/navigation.ts @@ -34,3 +34,11 @@ export const generateShareUrl = ( if (typeof window === 'undefined') return pathname; return `${window.location.origin}${pathname}`; }; + +export const buildUrl = (url: string, params: Record) => { + const urlObj = new URL(url); + Object.entries(params).forEach(([key, value]) => { + urlObj.searchParams.set(key, value); + }); + return urlObj.toString(); +};