refactor: collection controllers and service

This commit is contained in:
Sonny
2025-08-07 19:42:05 +02:00
parent a5ddc9eb55
commit 56b52adac0
15 changed files with 314 additions and 164 deletions

View File

@@ -31,14 +31,14 @@ type AuthLogoutGetHead = {
type DashboardGetHead = { type DashboardGetHead = {
request: unknown; request: unknown;
response: MakeTuyauResponse< response: MakeTuyauResponse<
import('../app/collections/controllers/collections_controller.ts').default['index'], import('../app/collections/controllers/show_collections_controller.ts').default['render'],
false false
>; >;
}; };
type CollectionsCreateGetHead = { type CollectionsCreateGetHead = {
request: unknown; request: unknown;
response: MakeTuyauResponse< response: MakeTuyauResponse<
import('../app/collections/controllers/collections_controller.ts').default['showCreatePage'], import('../app/collections/controllers/create_collection_controller.ts').default['render'],
false false
>; >;
}; };
@@ -49,14 +49,14 @@ type CollectionsPost = {
> >
>; >;
response: MakeTuyauResponse< response: MakeTuyauResponse<
import('../app/collections/controllers/collections_controller.ts').default['store'], import('../app/collections/controllers/create_collection_controller.ts').default['execute'],
true true
>; >;
}; };
type CollectionsEditGetHead = { type CollectionsEditGetHead = {
request: unknown; request: unknown;
response: MakeTuyauResponse< response: MakeTuyauResponse<
import('../app/collections/controllers/collections_controller.ts').default['showEditPage'], import('../app/collections/controllers/update_collection_controller.ts').default['render'],
false false
>; >;
}; };
@@ -67,14 +67,14 @@ type CollectionsIdPut = {
> >
>; >;
response: MakeTuyauResponse< response: MakeTuyauResponse<
import('../app/collections/controllers/collections_controller.ts').default['update'], import('../app/collections/controllers/update_collection_controller.ts').default['execute'],
true true
>; >;
}; };
type CollectionsDeleteGetHead = { type CollectionsDeleteGetHead = {
request: unknown; request: unknown;
response: MakeTuyauResponse< response: MakeTuyauResponse<
import('../app/collections/controllers/collections_controller.ts').default['showDeletePage'], import('../app/collections/controllers/delete_collection_controller.ts').default['render'],
false false
>; >;
}; };
@@ -85,7 +85,7 @@ type CollectionsIdDelete = {
> >
>; >;
response: MakeTuyauResponse< response: MakeTuyauResponse<
import('../app/collections/controllers/collections_controller.ts').default['delete'], import('../app/collections/controllers/delete_collection_controller.ts').default['execute'],
true true
>; >;
}; };

View File

@@ -8,9 +8,9 @@ NODE_ENV=development
SESSION_DRIVER=cookie SESSION_DRIVER=cookie
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=5432 DB_PORT=5432
DB_USER=postgres DB_USER=root
DB_PASSWORD=my-links-pwd DB_PASSWORD=root
DB_DATABASE=my-links DB_DATABASE=app
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
GOOGLE_CLIENT_CALLBACK_URL=http://localhost:3333/auth/callback GOOGLE_CLIENT_CALLBACK_URL=http://localhost:3333/auth/callback

View File

@@ -1,5 +1,5 @@
import AuthController from '#auth/controllers/auth_controller'; 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 LinksController from '#links/controllers/links_controller';
import User from '#user/models/user'; import User from '#user/models/user';
import { inject } from '@adonisjs/core'; import { inject } from '@adonisjs/core';

View File

@@ -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 },
});
}
}

View File

@@ -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 },
});
}
}

View File

@@ -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);
}
}

View File

@@ -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');
}
}

View File

@@ -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(),
});
}
}

View File

@@ -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);
}
}

View File

@@ -1,33 +1,45 @@
import { middleware } from '#start/kernel'; import { middleware } from '#start/kernel';
import router from '@adonisjs/core/services/router'; 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 router
.group(() => { .group(() => {
router.get('/dashboard', [CollectionsController, 'index']).as('dashboard'); router
.get('/dashboard', [ShowCollectionsController, 'render'])
.as('dashboard');
router router
.group(() => { .group(() => {
// Create
router router
.get('/create', [CollectionsController, 'showCreatePage']) .get('/create', [CreateCollectionController, 'render'])
.as('collection.create-form'); .as('collection.create-form');
router router
.post('/', [CollectionsController, 'store']) .post('/', [CreateCollectionController, 'execute'])
.as('collection.create'); .as('collection.create');
// Update
router router
.get('/edit', [CollectionsController, 'showEditPage']) .get('/edit', [UpdateCollectionController, 'render'])
.as('collection.edit-form'); .as('collection.edit-form');
router router
.put('/:id', [CollectionsController, 'update']) .put('/:id', [UpdateCollectionController, 'execute'])
.as('collection.edit'); .as('collection.edit');
// Delete
router router
.get('/delete', [CollectionsController, 'showDeletePage']) .get('/delete', [DeleteCollectionController, 'render'])
.as('collection.delete-form'); .as('collection.delete-form');
router router
.delete('/:id', [CollectionsController, 'delete']) .delete('/:id', [DeleteCollectionController, 'execute'])
.as('collection.delete'); .as('collection.delete');
}) })
.prefix('/collections'); .prefix('/collections');

View File

@@ -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;
}
}

View File

@@ -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 Link from '#links/models/link';
import { createLinkValidator } from '#links/validators/create_link_validator'; import { createLinkValidator } from '#links/validators/create_link_validator';
import { deleteLinkValidator } from '#links/validators/delete_link_validator'; import { deleteLinkValidator } from '#links/validators/delete_link_validator';

View File

@@ -1,7 +1,7 @@
import env from '#start/env'; import env from '#start/env';
import app from '@adonisjs/core/services/app';
import { Secret } from '@adonisjs/core/helpers'; import { Secret } from '@adonisjs/core/helpers';
import { defineConfig } from '@adonisjs/core/http'; import { defineConfig } from '@adonisjs/core/http';
import app from '@adonisjs/core/services/app';
/** /**
* The app key is used for encrypting cookies, generating signed URLs, * 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 * Enabling async local storage will let you access HTTP context
* from anywhere inside your application. * from anywhere inside your application.
*/ */
useAsyncLocalStorage: false, useAsyncLocalStorage: true,
/** /**
* Manage cookies configuration. The settings for the session id cookie are * Manage cookies configuration. The settings for the session id cookie are

View File

@@ -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<string, string>; // 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 UseRouteProps> = T extends TuyauRoute
? TuyauReturn
: NonTuyauReturn;
export const useRoute = <T extends UseRouteProps>(
props: T
): UseRouteReturn<T> => {
const tuyau = useTuyau();
if ('href' in props) {
return {
url: props.href,
} as UseRouteReturn<T>;
}
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<T>;
};

View File

@@ -34,3 +34,11 @@ export const generateShareUrl = (
if (typeof window === 'undefined') return pathname; if (typeof window === 'undefined') return pathname;
return `${window.location.origin}${pathname}`; return `${window.location.origin}${pathname}`;
}; };
export const buildUrl = (url: string, params: Record<string, string>) => {
const urlObj = new URL(url);
Object.entries(params).forEach(([key, value]) => {
urlObj.searchParams.set(key, value);
});
return urlObj.toString();
};