Initial commit
This commit is contained in:
Executable
+1
@@ -0,0 +1 @@
|
||||
client/
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
# http://editorconfig.org
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.json]
|
||||
insert_final_newline = unset
|
||||
|
||||
[**.min.js]
|
||||
indent_style = unset
|
||||
insert_final_newline = unset
|
||||
|
||||
[MakeFile]
|
||||
indent_style = space
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
TZ=UTC
|
||||
PORT=3333
|
||||
HOST=localhost
|
||||
LOG_LEVEL=info
|
||||
APP_KEY=
|
||||
NODE_ENV=development
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_USER=root
|
||||
DB_PASSWORD=root
|
||||
DB_DATABASE=app
|
||||
DRIVE_DISK=fs
|
||||
AWS_ACCESS_KEY_ID=minioadmin
|
||||
AWS_SECRET_ACCESS_KEY=minioadmin
|
||||
AWS_REGION=us-east-1
|
||||
S3_BUCKET=crux-drive
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_CDN_URL=http://localhost:9000/crux-drive/
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
# Adonis v6 backend (development mode)
|
||||
FROM node:22-alpine3.20
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only root workspace files first
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# Install workspace dependencies (monorepo-aware)
|
||||
RUN npm ci
|
||||
|
||||
# Copy backend source
|
||||
COPY backend ./backend
|
||||
COPY packages ./packages
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
# Expose dev port
|
||||
EXPOSE 3333
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| JavaScript entrypoint for running ace commands
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD
|
||||
| PROCESS.
|
||||
|
|
||||
| See docs.adonisjs.com/guides/typescript-build-process#creating-production-build
|
||||
|
|
||||
| Since, we cannot run TypeScript source code using "node" binary, we need
|
||||
| a JavaScript entrypoint to run ace commands.
|
||||
|
|
||||
| This file registers the "ts-node/esm" hook with the Node.js module system
|
||||
| and then imports the "bin/console.ts" file.
|
||||
|
|
||||
*/
|
||||
|
||||
/**
|
||||
* Register hook to process TypeScript files using ts-node
|
||||
*/
|
||||
import 'ts-node-maintained/register/esm'
|
||||
|
||||
/**
|
||||
* Import ace console entrypoint
|
||||
*/
|
||||
await import('./bin/console.js')
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
import { defineConfig } from '@adonisjs/core/app'
|
||||
|
||||
export default defineConfig({
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Experimental flags
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following features will be enabled by default in the next major release
|
||||
| of AdonisJS. You can opt into them today to avoid any breaking changes
|
||||
| during upgrade.
|
||||
|
|
||||
*/
|
||||
experimental: {
|
||||
mergeMultipartFieldsAndFiles: true,
|
||||
shutdownInReverseOrder: true,
|
||||
},
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Commands
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| List of ace commands to register from packages. The application commands
|
||||
| will be scanned automatically from the "./commands" directory.
|
||||
|
|
||||
*/
|
||||
commands: [
|
||||
() => import('@adonisjs/core/commands'),
|
||||
() => import('@adonisjs/lucid/commands'),
|
||||
() => import('@adonisjs/bouncer/commands'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Service providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| List of service providers to import and register when booting the
|
||||
| application
|
||||
|
|
||||
*/
|
||||
providers: [
|
||||
() => import('@adonisjs/core/providers/app_provider'),
|
||||
() => import('@adonisjs/core/providers/hash_provider'),
|
||||
{
|
||||
file: () => import('@adonisjs/core/providers/repl_provider'),
|
||||
environment: ['repl', 'test'],
|
||||
},
|
||||
() => import('@adonisjs/core/providers/vinejs_provider'),
|
||||
() => import('@adonisjs/cors/cors_provider'),
|
||||
() => import('@adonisjs/lucid/database_provider'),
|
||||
() => import('@adonisjs/auth/auth_provider'),
|
||||
() => import('@adonisjs/bouncer/bouncer_provider'),
|
||||
() => import('@adonisjs/drive/drive_provider'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Preloads
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| List of modules to import before starting the application.
|
||||
|
|
||||
*/
|
||||
preloads: [() => import('#start/routes'), () => import('#start/kernel')],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Tests
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| List of test suites to organize tests by their type. Feel free to remove
|
||||
| and add additional suites.
|
||||
|
|
||||
*/
|
||||
tests: {
|
||||
suites: [
|
||||
{
|
||||
name: 'functional',
|
||||
files: ['tests/**/*.spec.(ts|js)', 'modules/**/tests/**/*.spec.(ts|js)'],
|
||||
},
|
||||
],
|
||||
forceExit: false,
|
||||
},
|
||||
})
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Bouncer abilities
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may export multiple abilities from this file and pre-register them
|
||||
| when creating the Bouncer instance.
|
||||
|
|
||||
| Pre-registered policies and abilities can be referenced as a string by their
|
||||
| name. Also they are must if want to perform authorization inside Edge
|
||||
| templates.
|
||||
|
|
||||
*/
|
||||
|
||||
import { Bouncer } from '@adonisjs/bouncer'
|
||||
|
||||
/**
|
||||
* Delete the following ability to start from
|
||||
* scratch
|
||||
*/
|
||||
export const editUser = Bouncer.ability(() => {
|
||||
return true
|
||||
})
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
export default [
|
||||
{
|
||||
slug: 'admins.view',
|
||||
name: 'Admin view',
|
||||
},
|
||||
{
|
||||
slug: 'admins.create',
|
||||
name: 'Admin create',
|
||||
},
|
||||
{
|
||||
slug: 'admins.update',
|
||||
name: 'Admin update',
|
||||
},
|
||||
{
|
||||
slug: 'admins.delete',
|
||||
name: 'Admin delete',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,91 @@
|
||||
import { HttpContext } from '@adonisjs/core/http'
|
||||
import { createAdminValidator } from '#base_modules/Admin/validators/admin_create_validator'
|
||||
import { updateAdminValidator } from '#base_modules/Admin/validators/admin_update_validator'
|
||||
import { addSearchFilter, searchableColumns } from '#services/database_inspector'
|
||||
import Admins from '#base_modules/Admin/model/admin'
|
||||
import AdminsPolicy from '#base_modules/Admin/policy/admin_policy'
|
||||
import { updateProfileAdminValidator } from '#base_modules/Admin/validators/admin_update_profile_validator'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
|
||||
export default class AdminsController {
|
||||
public async index({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(AdminsPolicy).authorize('viewList')
|
||||
const {
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
search = null,
|
||||
sortBy = 'id',
|
||||
sortOrder = 'desc',
|
||||
} = request.all()
|
||||
const query = Admins.query()
|
||||
if (search) {
|
||||
const columns = Admins.searchable || (await searchableColumns('admins'))
|
||||
addSearchFilter(query, columns, search)
|
||||
}
|
||||
const result = await query.orderBy(sortBy, sortOrder).paginate(page, perPage)
|
||||
return response.ok(result)
|
||||
}
|
||||
|
||||
public async store({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(AdminsPolicy).authorize('create')
|
||||
const data = await createAdminValidator.validate(request.all())
|
||||
const record = await Admins.create(data)
|
||||
if (record) {
|
||||
await record.related('roles').sync(data.roles)
|
||||
return response.created({ message: 'Successfully created.' })
|
||||
}
|
||||
return response.badRequest({ message: 'Cannot create.' })
|
||||
}
|
||||
|
||||
public async show({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(AdminsPolicy).authorize('view')
|
||||
const { id } = request.params()
|
||||
if (!id) return response.notFound({ message: 'Not found.' })
|
||||
const record = await Admins.find(id)
|
||||
if (!record) return response.notFound({ message: 'Not found.' })
|
||||
await record.load('roles')
|
||||
return response.ok(record)
|
||||
}
|
||||
|
||||
public async update({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(AdminsPolicy).authorize('update')
|
||||
const { id } = request.params()
|
||||
if (!id) return response.notFound({ message: 'Not found.' })
|
||||
const data = await updateAdminValidator.validate(request.all())
|
||||
const record = await Admins.find(id)
|
||||
if (!record) return response.notFound({ message: 'Not found.' })
|
||||
record.merge(data)
|
||||
await record.save()
|
||||
await record.related('roles').sync(data.roles)
|
||||
return response.ok({ message: 'Updated.' })
|
||||
}
|
||||
|
||||
public async destroy({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(AdminsPolicy).authorize('delete')
|
||||
const { id } = request.params()
|
||||
if (!id) return response.notFound({ message: 'Not found.' })
|
||||
const admins = await Admins.all()
|
||||
if (admins.length === 1) {
|
||||
return response.badRequest({ message: "Last Admin can't be deleted!" })
|
||||
}
|
||||
const record = await Admins.find(id)
|
||||
if (!record) return response.notFound({ message: 'Not found.' })
|
||||
await Admins.query().where('id', id).delete()
|
||||
return response.ok({ message: 'Deleted.' })
|
||||
}
|
||||
|
||||
public async updateProfile({ request, response, auth }: HttpContext) {
|
||||
const user = auth.use('api').user
|
||||
if (!user) return response.unauthorized()
|
||||
const data = await updateProfileAdminValidator.validate(request.all())
|
||||
user.merge(data)
|
||||
user.save()
|
||||
await user.load('roles')
|
||||
const roles = user.roles.map((role) => role.id)
|
||||
const permissions = await db
|
||||
.from('role_permissions')
|
||||
.whereIn('role_id', roles)
|
||||
.select('permission_slug')
|
||||
return response.ok({ user, permissions: permissions.map((perm) => perm.permission_slug) })
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import hash from '@adonisjs/core/services/hash'
|
||||
import { compose } from '@adonisjs/core/helpers'
|
||||
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
|
||||
import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
|
||||
import { BaseModel, column, manyToMany } from '@adonisjs/lucid/orm'
|
||||
import type { ManyToMany } from '@adonisjs/lucid/types/relations'
|
||||
import { DateTime } from 'luxon'
|
||||
import Role from '#base_modules/Role/model/role'
|
||||
import Permission from '#base_modules/Permission/model/permission'
|
||||
|
||||
const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
|
||||
uids: ['email'],
|
||||
passwordColumnName: 'password',
|
||||
})
|
||||
|
||||
const ComposedModel = compose(BaseModel, AuthFinder) as typeof BaseModel
|
||||
|
||||
export default class Admins extends ComposedModel {
|
||||
static searchable = {
|
||||
string: ['email', 'first_name', 'last_name'],
|
||||
number: ['id'],
|
||||
}
|
||||
|
||||
@column({ isPrimary: true, columnName: 'id' })
|
||||
declare id: number
|
||||
|
||||
@column({ columnName: 'first_name' })
|
||||
declare firstName: string | null
|
||||
|
||||
@column({ columnName: 'last_name' })
|
||||
declare lastName: string | null
|
||||
|
||||
@column({ columnName: 'email' })
|
||||
declare email: string
|
||||
|
||||
@column({ columnName: 'status' })
|
||||
declare status: 'active' | 'inactive'
|
||||
|
||||
@column({ serializeAs: null, columnName: 'password' })
|
||||
declare password: string
|
||||
|
||||
@column.dateTime({ autoCreate: true, columnName: 'created_at' })
|
||||
declare createdAt: DateTime
|
||||
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true, columnName: 'updated_at' })
|
||||
declare updatedAt: DateTime | null
|
||||
|
||||
@manyToMany(() => Role, {
|
||||
pivotTable: 'admin_roles',
|
||||
pivotForeignKey: 'admin_id',
|
||||
pivotRelatedForeignKey: 'role_id',
|
||||
})
|
||||
declare roles: ManyToMany<typeof Role>
|
||||
|
||||
declare permissions: Permission[]
|
||||
|
||||
static accessTokens = DbAccessTokensProvider.forModel(Admins)
|
||||
|
||||
async loadPermissions() {
|
||||
this.permissions = await Permission.query()
|
||||
.join('role_permissions', 'permissions.id', 'role_permissions.permission_id')
|
||||
.join('admin_roles', 'role_permissions.role_id', 'admin_roles.role_id')
|
||||
.where('admin_roles.admin_id', this.id)
|
||||
.exec()
|
||||
return this
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { BasePolicy } from '@adonisjs/bouncer'
|
||||
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
|
||||
import Admins from '#base_modules/Admin/model/admin'
|
||||
import { getAdminPermissionSlugs } from '#services/permission_service'
|
||||
|
||||
export default class AdminsPolicy extends BasePolicy {
|
||||
// Example permission slugs: admins.view, admins.create, admins.update, admins.delete
|
||||
async viewList(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('admins.view')
|
||||
}
|
||||
|
||||
async view(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('admins.view')
|
||||
}
|
||||
|
||||
async create(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('admins.create')
|
||||
}
|
||||
|
||||
async update(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('admins.update')
|
||||
}
|
||||
|
||||
async delete(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('admins.delete')
|
||||
}
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
import router from '@adonisjs/core/services/router'
|
||||
import { middleware } from '#start/kernel'
|
||||
|
||||
const adminController = () => import('#base_modules/Admin/controller/admin_controller')
|
||||
|
||||
router.put('/admin/update-profile', [adminController, 'updateProfile']).use([middleware.auth()])
|
||||
router.resource('/admin', adminController).apiOnly().use('*', [middleware.auth()]) //.use(['store', 'update', 'destroy'], [middleware.auth()])
|
||||
@@ -0,0 +1,20 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const createAdminValidator = vine.compile(
|
||||
vine.object({
|
||||
id: vine.number().optional(),
|
||||
email: vine
|
||||
.string()
|
||||
.trim()
|
||||
.email()
|
||||
.unique(async (db, value) => {
|
||||
const exists = await db.from('admins').where('email', value).first()
|
||||
return !exists
|
||||
}),
|
||||
password: vine.string().trim().minLength(8),
|
||||
firstName: vine.string().trim().optional(),
|
||||
lastName: vine.string().trim().optional(),
|
||||
status: vine.enum(['active', 'inactive']),
|
||||
roles: vine.array(vine.number()),
|
||||
})
|
||||
)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const updateProfileAdminValidator = vine.compile(
|
||||
vine.object({
|
||||
id: vine.number(),
|
||||
email: vine
|
||||
.string()
|
||||
.trim()
|
||||
.email()
|
||||
.unique(async (db, value, field) => {
|
||||
const id = field.data.id
|
||||
const exists = await db.from('admins').where('email', value).whereNot('id', id).first()
|
||||
return !exists
|
||||
}),
|
||||
password: vine.string().trim().minLength(8).optional(),
|
||||
firstName: vine.string().trim().minLength(1),
|
||||
lastName: vine.string().trim().minLength(1),
|
||||
status: vine.enum(['active', 'inactive']).optional(),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const updateAdminValidator = vine.compile(
|
||||
vine.object({
|
||||
id: vine.number().optional(),
|
||||
email: vine
|
||||
.string()
|
||||
.trim()
|
||||
.email()
|
||||
.unique(async (db, value, field) => {
|
||||
const id = field.data.id
|
||||
const exists = await db.from('admins').where('email', value).whereNot('id', id).first()
|
||||
return !exists
|
||||
})
|
||||
.optional(),
|
||||
password: vine.string().trim().minLength(8).optional(),
|
||||
firstName: vine.string().trim().optional(),
|
||||
lastName: vine.string().trim().optional(),
|
||||
status: vine.enum(['active', 'inactive']).optional(),
|
||||
roles: vine.array(vine.number()),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
import Admin from '#base_modules/Admin/model/admin'
|
||||
import { HttpContext } from '@adonisjs/core/http'
|
||||
import hash from '@adonisjs/core/services/hash'
|
||||
import { authLoginValidator } from '#base_modules/Auth/validators/auth_login_validator'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
|
||||
export default class AuthController {
|
||||
async login({ request, response }: HttpContext) {
|
||||
const { email, password } = await request.validateUsing(authLoginValidator)
|
||||
const admin = await Admin.findBy('email', email)
|
||||
if (!admin) {
|
||||
return response.unauthorized({
|
||||
message: 'Invalid credentials.',
|
||||
})
|
||||
}
|
||||
|
||||
const verified = await hash.verify(admin.password, password)
|
||||
if (!verified) {
|
||||
return response.unauthorized({
|
||||
message: 'Invalid credentials.',
|
||||
})
|
||||
}
|
||||
|
||||
const token = await Admin.accessTokens.create(admin)
|
||||
|
||||
return response.ok({
|
||||
type: 'bearer',
|
||||
token: token.value?.release(),
|
||||
user: admin,
|
||||
})
|
||||
}
|
||||
|
||||
async logout({ auth }: HttpContext) {
|
||||
await auth.use('api').invalidateToken()
|
||||
return { message: 'Logged out' }
|
||||
}
|
||||
|
||||
async user({ response, auth }: HttpContext) {
|
||||
const user = await auth.use('api').user
|
||||
if (!user) return response.unauthorized()
|
||||
await user.load('roles')
|
||||
const roles = user.roles.map((role) => role.id)
|
||||
const permissions = await db
|
||||
.from('role_permissions')
|
||||
.whereIn('role_id', roles)
|
||||
.select('permission_slug')
|
||||
return response.ok({ user, permissions: permissions.map((perm) => perm.permission_slug) })
|
||||
}
|
||||
}
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
import { middleware } from '#start/kernel'
|
||||
import router from '@adonisjs/core/services/router'
|
||||
|
||||
const AuthController = () => import('#base_modules/Auth/controller/auth_controller')
|
||||
|
||||
router.post('/auth/login', [AuthController, 'login'])
|
||||
router.post('/auth/logout', [AuthController, 'logout']).use([middleware.auth()])
|
||||
router.get('/auth/user', [AuthController, 'user']).use([middleware.auth()])
|
||||
@@ -0,0 +1,8 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const authLoginValidator = vine.compile(
|
||||
vine.object({
|
||||
email: vine.string().trim().email(),
|
||||
password: vine.string().trim().minLength(8),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
export const ROOT_PATH = '/'
|
||||
@@ -0,0 +1,18 @@
|
||||
export default [
|
||||
{
|
||||
slug: 'file_manager.view',
|
||||
name: 'File Manager view',
|
||||
},
|
||||
{
|
||||
slug: 'file_manager.create',
|
||||
name: 'File Manager create',
|
||||
},
|
||||
{
|
||||
slug: 'file_manager.update',
|
||||
name: 'File Manager update',
|
||||
},
|
||||
{
|
||||
slug: 'file_manager.delete',
|
||||
name: 'File Manager delete',
|
||||
},
|
||||
]
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import { HttpContext } from '@adonisjs/core/http'
|
||||
import listValidator from '#base_modules/FileManager/validator/list_validator'
|
||||
import uploadFileValidator from '#base_modules/FileManager/validator/upload_file_validator'
|
||||
import createDirectoryValidator from '#base_modules/FileManager/validator/create_directory_validator'
|
||||
import renameEntryValidator from '#base_modules/FileManager/validator/rename_entry_validator'
|
||||
import duplicateEntriesValidator from '#base_modules/FileManager/validator/duplicate_entries_validator'
|
||||
import FileMangerPolicy from '#base_modules/FileManager/policy/file_manager_policy'
|
||||
import { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||
import getValidatedPathFromRequest from '#base_modules/FileManager/helpers/get_validated_path_from_request'
|
||||
import FileManagerService from '#base_modules/FileManager/services/file_manager_service'
|
||||
|
||||
export default class FileManagerController {
|
||||
private fileManagerService = new FileManagerService()
|
||||
|
||||
async index({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('viewList')
|
||||
|
||||
const path = await getValidatedPathFromRequest(request)
|
||||
|
||||
const query = request.all()
|
||||
|
||||
const params = await listValidator.validate({
|
||||
filterBy: query?.filterBy ?? 'all',
|
||||
sortBy: query?.sortBy ?? 'name',
|
||||
sortOrder: query?.sortOrder ?? 'asc',
|
||||
maxResults: query?.maxResults,
|
||||
paginationToken: query?.paginationToken,
|
||||
})
|
||||
|
||||
const entries = await this.fileManagerService.listDirectory(path, params)
|
||||
|
||||
const payload: {
|
||||
data: Entry[]
|
||||
meta: {
|
||||
nextPaginationToken: string | null
|
||||
}
|
||||
} = {
|
||||
data: entries.data,
|
||||
meta: entries.meta,
|
||||
}
|
||||
|
||||
return response.ok(payload)
|
||||
}
|
||||
|
||||
async show({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('view')
|
||||
|
||||
const path = await getValidatedPathFromRequest(request)
|
||||
const result = await this.fileManagerService.getFileEntryWithPreview(path)
|
||||
|
||||
const payload: { data: FileEntry & { content?: string } } = {
|
||||
data: result,
|
||||
}
|
||||
|
||||
return response.ok(payload)
|
||||
}
|
||||
|
||||
public async raw({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('view')
|
||||
|
||||
const path = await getValidatedPathFromRequest(request)
|
||||
const { stream, contentType, size, name } = await this.fileManagerService.getFileStream(path)
|
||||
|
||||
if (contentType) response.header('Content-Type', contentType)
|
||||
if (size !== undefined) response.header('Content-Length', String(size))
|
||||
response.header('Content-Disposition', `inline; filename="${encodeURIComponent(name)}"`)
|
||||
|
||||
return response.stream(stream)
|
||||
}
|
||||
|
||||
async createFolder({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('create')
|
||||
|
||||
const { name, path } = await createDirectoryValidator.validate(request.all())
|
||||
|
||||
const result = await this.fileManagerService.createDirectory(path, name)
|
||||
|
||||
const payload: { data: FolderEntry } = {
|
||||
data: result,
|
||||
}
|
||||
|
||||
return response.created(payload)
|
||||
}
|
||||
|
||||
async destroy({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('delete')
|
||||
|
||||
const path = await getValidatedPathFromRequest(request)
|
||||
|
||||
await this.fileManagerService.deleteEntry(path)
|
||||
|
||||
return response.noContent()
|
||||
}
|
||||
|
||||
async rename({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('update')
|
||||
|
||||
const { name, path } = await renameEntryValidator.validate(request.all())
|
||||
|
||||
const entry = await this.fileManagerService.moveEntry(path, name)
|
||||
|
||||
const payload: { data: Entry } = { data: entry }
|
||||
|
||||
return response.ok(payload)
|
||||
}
|
||||
|
||||
async upload({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('create')
|
||||
|
||||
const { file, path } = await uploadFileValidator.validate(request.all())
|
||||
|
||||
const entry = await this.fileManagerService.writeFile(path, file)
|
||||
|
||||
const payload: { data: Entry } = { data: entry }
|
||||
|
||||
return response.created(payload)
|
||||
}
|
||||
|
||||
async duplicate({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('create')
|
||||
|
||||
const { paths } = await duplicateEntriesValidator.validate(request.all())
|
||||
|
||||
const results = await this.fileManagerService.copyEntries(paths)
|
||||
|
||||
const payload: { data: Entry[] } = { data: results }
|
||||
|
||||
return response.created(payload)
|
||||
}
|
||||
|
||||
async search({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('viewList')
|
||||
|
||||
const query = request.input('q', '')
|
||||
|
||||
if (!query.trim()) {
|
||||
return response.badRequest({ message: 'Search query is required' })
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
request.request.on('close', () => abortController.abort())
|
||||
|
||||
const results = await this.fileManagerService.search('/', query, abortController.signal)
|
||||
|
||||
const payload: { data: Entry[] } = { data: results }
|
||||
|
||||
return response.ok(payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import type { Request } from '@adonisjs/core/http'
|
||||
import { pathSchema } from '#base_modules/FileManager/validator/schemas'
|
||||
import { ROOT_PATH } from '#base_modules/FileManager/config/general'
|
||||
import PathService from '#base_modules/FileManager/services/path_service'
|
||||
|
||||
const pathService = new PathService()
|
||||
|
||||
export default async function getValidatedPathFromRequest(request: Request): Promise<string> {
|
||||
const segments = request.param('*', [ROOT_PATH])
|
||||
const path = pathService.join(...segments.map(decodeURIComponent))
|
||||
return vine.compile(pathSchema).validate(path)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BasePolicy } from '@adonisjs/bouncer'
|
||||
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
|
||||
import Admins from '#base_modules/Admin/model/admin'
|
||||
import { getAdminPermissionSlugs } from '#services/permission_service'
|
||||
|
||||
export default class FileMangerPolicy extends BasePolicy {
|
||||
async viewList(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('file_manager.view')
|
||||
}
|
||||
|
||||
async view(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('file_manager.view')
|
||||
}
|
||||
|
||||
async create(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('file_manager.create')
|
||||
}
|
||||
|
||||
async update(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('file_manager.update')
|
||||
}
|
||||
|
||||
async delete(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('file_manager.delete')
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import router from '@adonisjs/core/services/router'
|
||||
import { middleware } from '#start/kernel'
|
||||
|
||||
const FileManagerController = () =>
|
||||
import('#base_modules/FileManager/controller/file_manager_controller')
|
||||
|
||||
router
|
||||
.group(() => {
|
||||
router.get('/', [FileManagerController, 'index'])
|
||||
|
||||
router.put('/entries/rename', [FileManagerController, 'rename'])
|
||||
router.post('/folders', [FileManagerController, 'createFolder'])
|
||||
|
||||
router.get('/files/raw/*', [FileManagerController, 'raw'])
|
||||
router.get('/files/details/*', [FileManagerController, 'show'])
|
||||
router.post('/files', [FileManagerController, 'upload'])
|
||||
router.post('/files/duplicate', [FileManagerController, 'duplicate'])
|
||||
|
||||
router.get('/search', [FileManagerController, 'search'])
|
||||
|
||||
router.delete('/*', [FileManagerController, 'destroy'])
|
||||
router.get('/*', [FileManagerController, 'index'])
|
||||
})
|
||||
.prefix('/file-manager')
|
||||
.use([middleware.auth()])
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Entry } from '#base_modules/FileManager/types/entries'
|
||||
|
||||
export type EntryFilter = 'media' | 'archives' | 'documents' | 'all'
|
||||
export type EntrySortBy = 'name' | 'size' | 'type' | 'date'
|
||||
export type EntrySortOrder = 'asc' | 'desc'
|
||||
|
||||
export interface EntryListParams {
|
||||
filterBy?: EntryFilter
|
||||
sortBy?: EntrySortBy
|
||||
sortOrder?: EntrySortOrder
|
||||
maxResults?: number
|
||||
paginationToken?: string
|
||||
}
|
||||
|
||||
export interface EntryListResult {
|
||||
data: Entry[]
|
||||
meta: {
|
||||
nextPaginationToken: string | null
|
||||
}
|
||||
}
|
||||
|
||||
interface PaginationTokenPayload {
|
||||
offset: number
|
||||
}
|
||||
|
||||
const FILTER_EXTENSIONS: Record<Exclude<EntryFilter, 'all'>, string[]> = {
|
||||
media: ['jpg', 'jpeg', 'png', 'gif', 'mp4', 'mp3', 'wav'],
|
||||
archives: ['zip', 'rar', '7z'],
|
||||
documents: ['pdf', 'doc', 'docx', 'txt'],
|
||||
}
|
||||
|
||||
export default class EntryListService {
|
||||
public apply(entries: Entry[], params: EntryListParams = {}): EntryListResult {
|
||||
const {
|
||||
filterBy = 'all',
|
||||
sortBy = 'name',
|
||||
sortOrder = 'asc',
|
||||
maxResults = 25,
|
||||
paginationToken,
|
||||
} = params
|
||||
|
||||
const limit = Math.min(Math.max(maxResults, 1), 100)
|
||||
const decoded = this.decodeToken(paginationToken)
|
||||
const offset = decoded?.offset ?? 0
|
||||
|
||||
const result = this.sortEntries(this.filterEntries(entries, filterBy), sortBy, sortOrder)
|
||||
const data = result.slice(offset, offset + limit)
|
||||
const nextOffset = offset + limit
|
||||
const nextPaginationToken =
|
||||
nextOffset < result.length
|
||||
? this.encodeToken({
|
||||
offset: nextOffset,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
filterBy,
|
||||
})
|
||||
: null
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
nextPaginationToken,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private filterEntries(entries: Entry[], filterBy: EntryFilter): Entry[] {
|
||||
if (filterBy === 'all') return entries
|
||||
|
||||
return entries.filter((entry) => {
|
||||
if (entry.type === 'folder') return true
|
||||
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (!ext) return false
|
||||
|
||||
return FILTER_EXTENSIONS[filterBy].includes(ext)
|
||||
})
|
||||
}
|
||||
|
||||
private sortEntries(entries: Entry[], sortBy: EntrySortBy, sortOrder: EntrySortOrder): Entry[] {
|
||||
return [...entries].sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'folder' ? -1 : 1
|
||||
}
|
||||
|
||||
let valA: string | number
|
||||
let valB: string | number
|
||||
|
||||
switch (sortBy) {
|
||||
case 'size':
|
||||
valA = a.type === 'file' ? (a.size ?? 0) : 0
|
||||
valB = b.type === 'file' ? (b.size ?? 0) : 0
|
||||
break
|
||||
|
||||
case 'date':
|
||||
valA = a.type === 'file' ? new Date(a.updatedAt ?? 0).getTime() : 0
|
||||
valB = b.type === 'file' ? new Date(b.updatedAt ?? 0).getTime() : 0
|
||||
break
|
||||
|
||||
case 'type':
|
||||
valA = a.name.split('.').pop() ?? ''
|
||||
valB = b.name.split('.').pop() ?? ''
|
||||
break
|
||||
|
||||
case 'name':
|
||||
default:
|
||||
valA = a.name.toLowerCase()
|
||||
valB = b.name.toLowerCase()
|
||||
}
|
||||
|
||||
if (valA < valB) return sortOrder === 'desc' ? 1 : -1
|
||||
if (valA > valB) return sortOrder === 'desc' ? -1 : 1
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
private encodeToken(payload: object): string {
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64')
|
||||
}
|
||||
|
||||
private decodeToken(token?: string): PaginationTokenPayload | null {
|
||||
if (!token) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(Buffer.from(token, 'base64').toString('utf-8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Entry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||
|
||||
export default class EntrySearchService {
|
||||
public search(entries: Entry[], query: string, signal?: AbortSignal): Entry[] {
|
||||
if (signal?.aborted) return []
|
||||
|
||||
const results: Entry[] = []
|
||||
const addedFolders = new Set<string>()
|
||||
const q = query.toLowerCase()
|
||||
|
||||
for (const entry of entries) {
|
||||
if (signal?.aborted) return results
|
||||
|
||||
if (entry.type === 'folder') {
|
||||
this.addFolderIfMatches(entry, q, addedFolders, results)
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.path.toLowerCase().includes(q)) {
|
||||
results.push(entry)
|
||||
}
|
||||
|
||||
for (const folder of this.getFileParentFolders(entry.path)) {
|
||||
this.addFolderIfMatches(folder, q, addedFolders, results)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private addFolderIfMatches(
|
||||
folder: FolderEntry,
|
||||
query: string,
|
||||
addedFolders: Set<string>,
|
||||
results: Entry[]
|
||||
) {
|
||||
if (!folder.path.toLowerCase().includes(query) || addedFolders.has(folder.path)) return
|
||||
|
||||
addedFolders.add(folder.path)
|
||||
results.push(folder)
|
||||
}
|
||||
|
||||
private getFileParentFolders(path: string): FolderEntry[] {
|
||||
const segments = path.replace(/^\/+/, '').split('/').slice(0, -1)
|
||||
const folders: FolderEntry[] = []
|
||||
let current = ''
|
||||
|
||||
for (const segment of segments) {
|
||||
current = current ? `${current}/${segment}` : segment
|
||||
|
||||
folders.push({
|
||||
name: segment,
|
||||
path: current,
|
||||
type: 'folder',
|
||||
})
|
||||
}
|
||||
|
||||
return folders
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { MultipartFile } from '@adonisjs/core/bodyparser'
|
||||
import { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||
import {
|
||||
default as EntryListService,
|
||||
EntryListParams,
|
||||
EntryListResult,
|
||||
} from '#base_modules/FileManager/services/entry_list_service'
|
||||
import createStorageService from '#base_modules/FileManager/services/storage_service/create_storage_service'
|
||||
import {
|
||||
FileStream,
|
||||
StorageService,
|
||||
} from '#base_modules/FileManager/services/storage_service/storage_service_interface'
|
||||
import FilePreviewService from '#base_modules/FileManager/services/file_preview_service'
|
||||
import EntrySearchService from '#base_modules/FileManager/services/entry_search_service'
|
||||
import PathService from '#base_modules/FileManager/services/path_service'
|
||||
import {
|
||||
EntryExistsException,
|
||||
FilenameMissingExtensionException,
|
||||
} from '#exceptions/file_manager_exeptions'
|
||||
|
||||
export default class FileManagerService {
|
||||
private filePreviewService = new FilePreviewService()
|
||||
private entrySearchService = new EntrySearchService()
|
||||
private entryListService = new EntryListService()
|
||||
private pathService = new PathService()
|
||||
private storageService: StorageService
|
||||
|
||||
constructor(storageService?: StorageService) {
|
||||
if (storageService) this.storageService = storageService
|
||||
else this.storageService = createStorageService()
|
||||
}
|
||||
|
||||
public async listDirectory(path: string, params: EntryListParams): Promise<EntryListResult> {
|
||||
const entries = await this.storageService.listDirectoryEntries(path)
|
||||
|
||||
return this.entryListService.apply(entries, params)
|
||||
}
|
||||
|
||||
public async getFileEntryWithPreview(path: string): Promise<FileEntry & { content?: string }> {
|
||||
const entry = await this.storageService.getFileEntry(path)
|
||||
|
||||
return this.filePreviewService.withPreview(entry, () =>
|
||||
this.storageService.getFileContent(path)
|
||||
)
|
||||
}
|
||||
|
||||
public getFileStream(path: string): Promise<FileStream> {
|
||||
return this.storageService.getFileStream(path)
|
||||
}
|
||||
|
||||
public async createDirectory(path: string, name: string): Promise<FolderEntry> {
|
||||
const result = await this.storageService.createDirectory(path, name)
|
||||
|
||||
return {
|
||||
name,
|
||||
path: result.path,
|
||||
type: 'folder',
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteEntry(path: string): Promise<void> {
|
||||
await this.storageService.deleteEntry(path)
|
||||
}
|
||||
|
||||
public async moveEntry(path: string, name: string): Promise<Entry> {
|
||||
const entry = await this.storageService.getEntry(path)
|
||||
const dir = this.pathService.dirname(path)
|
||||
const newPath = dir ? this.pathService.join(dir, name) : name
|
||||
|
||||
if (entry.type === 'file' && !this.pathService.getFileNameMeta(name).ext) {
|
||||
throw new FilenameMissingExtensionException()
|
||||
}
|
||||
|
||||
if (await this.storageService.entryExists(newPath)) {
|
||||
throw new EntryExistsException()
|
||||
}
|
||||
|
||||
const result = await this.storageService.moveEntry(path, newPath)
|
||||
|
||||
return this.storageService.getEntry(result.path)
|
||||
}
|
||||
|
||||
public async writeFile(path: string, file: MultipartFile): Promise<Entry> {
|
||||
const dir = this.pathService.toStoragePath(path)
|
||||
const safeName = await this.pathService.generateUniqueName(dir, file.clientName, (target) => {
|
||||
return this.storageService.entryExists(target)
|
||||
})
|
||||
const writePath = this.pathService.join(dir, safeName)
|
||||
const result = await this.storageService.writeFile(writePath, file)
|
||||
|
||||
return this.storageService.getEntry(result.path)
|
||||
}
|
||||
|
||||
public async copyEntries(paths: string[]): Promise<Entry[]> {
|
||||
const results: Entry[] = []
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const sourceEntry = await this.storageService.getFileEntry(path)
|
||||
const dir = this.pathService.dirname(sourceEntry.path)
|
||||
const newName = await this.pathService.generateUniqueName(
|
||||
dir,
|
||||
sourceEntry.name,
|
||||
(target) => this.storageService.entryExists(target),
|
||||
(data) => {
|
||||
const template = 'copy_'
|
||||
const regex = new RegExp(`\\s?${template}(\\d+)$`)
|
||||
const base = data.base.replace(regex, '').trim()
|
||||
|
||||
return `${base} ${template}${data.count}${data.extension}`
|
||||
}
|
||||
)
|
||||
const copyPath = this.pathService.join(dir, newName)
|
||||
const result = await this.storageService.copyEntry(path, copyPath)
|
||||
const copiedEntry = await this.storageService.getFileEntry(result.path)
|
||||
|
||||
results.push(copiedEntry)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
public async search(path: string, query: string, signal?: AbortSignal): Promise<Entry[]> {
|
||||
const entries = await this.storageService.listAllEntries(path)
|
||||
|
||||
return this.entrySearchService.search(entries, query, signal)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { FileEntry } from '#base_modules/FileManager/types/entries'
|
||||
|
||||
const PREVIEWABLE_TEXT_EXTENSIONS = ['txt', 'md', 'log', 'json', 'csv']
|
||||
|
||||
export default class FilePreviewService {
|
||||
public async withPreview(
|
||||
entry: FileEntry,
|
||||
getContent: () => Promise<string>
|
||||
): Promise<FileEntry & { content?: string }> {
|
||||
if (!this.isPreviewableTextFile(entry.name)) return entry
|
||||
|
||||
const content = await getContent()
|
||||
|
||||
return { ...entry, content }
|
||||
}
|
||||
|
||||
private isPreviewableTextFile(name: string): boolean {
|
||||
const fileExt = name.split('.').pop()?.toLowerCase()
|
||||
|
||||
return PREVIEWABLE_TEXT_EXTENSIONS.includes(fileExt as string)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { cuid } from '@adonisjs/core/helpers'
|
||||
import { posix } from 'node:path'
|
||||
|
||||
export interface FileNameMeta {
|
||||
name: string
|
||||
ext: string
|
||||
}
|
||||
|
||||
export interface UniqueNameFormatterParams {
|
||||
base: string
|
||||
count: number
|
||||
extension: string
|
||||
}
|
||||
|
||||
export type UniqueNameFormatter = (params: UniqueNameFormatterParams) => string
|
||||
|
||||
export default class PathService {
|
||||
public toStoragePath(path: string = ''): string {
|
||||
let safePath = path.replace(/^(\.\.(\/|\\|$))+/, '')
|
||||
safePath = safePath.startsWith('/') ? safePath.slice(1) : safePath
|
||||
|
||||
return safePath
|
||||
}
|
||||
|
||||
public toClientPath(path: string = ''): string {
|
||||
return path.replace(/^\/?uploads\//, '/')
|
||||
}
|
||||
|
||||
public join(...parts: string[]): string {
|
||||
return posix.join(...parts)
|
||||
}
|
||||
|
||||
public dirname(path: string): string {
|
||||
return this.toStoragePath(path).split('/').slice(0, -1).join('/')
|
||||
}
|
||||
|
||||
public basename(path: string): string {
|
||||
return path.split('/').reverse()[0]
|
||||
}
|
||||
|
||||
public getFileNameMeta(name: string): FileNameMeta {
|
||||
const extIndex = name.lastIndexOf('.')
|
||||
const rawName = extIndex !== -1 ? name.slice(0, extIndex) : name
|
||||
const ext = extIndex !== -1 ? name.slice(extIndex).trim() : ''
|
||||
|
||||
return {
|
||||
name: rawName,
|
||||
ext,
|
||||
}
|
||||
}
|
||||
|
||||
public sanitizeFileName(name: string): string {
|
||||
return name.replace(/[()]/g, '').replace(/[^a-zA-Z0-9.\-_ ]/g, '')
|
||||
}
|
||||
|
||||
public async generateUniqueName(
|
||||
dir: string,
|
||||
fileName: string,
|
||||
exists: (path: string) => Promise<boolean>,
|
||||
formatter?: UniqueNameFormatter
|
||||
): Promise<string> {
|
||||
const { ext, name } = this.getFileNameMeta(fileName)
|
||||
const safeBase = this.sanitizeFileName(name)
|
||||
|
||||
let counter = 1
|
||||
let newName = `${safeBase}${ext}`
|
||||
|
||||
while (await exists(this.join(dir, newName))) {
|
||||
newName =
|
||||
typeof formatter === 'function'
|
||||
? formatter({ base: safeBase, count: counter, extension: ext })
|
||||
: `${safeBase}-${counter}${ext}`
|
||||
|
||||
counter++
|
||||
|
||||
if (counter > 5) {
|
||||
newName = `${cuid()}${ext}`
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return newName
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import env from '#start/env'
|
||||
import { StorageService } from './storage_service_interface.js'
|
||||
import { FSStorageService } from './fs_storage_service.js'
|
||||
import { S3StorageService } from './s3_storage_service.js'
|
||||
|
||||
type StorageDisk = 'fs' | 's3'
|
||||
|
||||
export default function createStorageService(
|
||||
disk: StorageDisk = env.get('DRIVE_DISK')
|
||||
): StorageService {
|
||||
switch (disk) {
|
||||
case 'fs':
|
||||
return new FSStorageService()
|
||||
|
||||
case 's3':
|
||||
return new S3StorageService()
|
||||
|
||||
default:
|
||||
throw new Error('Unsupported: ' + disk)
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
export default class FSDirectoryMarkerService {
|
||||
public markerName = '.keep'
|
||||
|
||||
public markerPath(path: string): string {
|
||||
return `${path}/${this.markerName}`
|
||||
}
|
||||
|
||||
public isMarkerName(name: string): boolean {
|
||||
return name === this.markerName
|
||||
}
|
||||
|
||||
public isMarkerPath(path: string): boolean {
|
||||
return path.endsWith(`/${this.markerName}`)
|
||||
}
|
||||
|
||||
public directoryPathFromMarker(path: string): string {
|
||||
return path.replace(new RegExp(`/${this.markerName}$`), '')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import drive from '@adonisjs/drive/services/main'
|
||||
import {
|
||||
DirExistsException,
|
||||
DirNotFoundException,
|
||||
FileNotFoundException,
|
||||
} from '#exceptions/file_manager_exeptions'
|
||||
import { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||
import { StorageService } from './storage_service_interface.js'
|
||||
import PathService from '#base_modules/FileManager/services/path_service'
|
||||
import FSDirectoryMarkerService from '#base_modules/FileManager/services/storage_service/fs_directory_marker_service'
|
||||
|
||||
export class FSStorageService implements StorageService {
|
||||
private disk = drive.use('fs')
|
||||
private pathService = new PathService()
|
||||
private directoryMarkerService = new FSDirectoryMarkerService()
|
||||
|
||||
private async moveDirectory(oldPath: string, newPath: string) {
|
||||
const response = await this.disk.listAll(oldPath, { recursive: true })
|
||||
|
||||
for (const item of response.objects) {
|
||||
if (!item.isFile) continue
|
||||
|
||||
const relative = item.key.slice(oldPath.length).replace(/^\/+/, '')
|
||||
|
||||
const target = `${newPath}/${relative}`
|
||||
|
||||
const dir = target.split('/').slice(0, -1).join('/')
|
||||
|
||||
if (dir) await this.disk.put(this.directoryMarkerService.markerPath(dir), '')
|
||||
|
||||
await this.disk.move(item.key, target)
|
||||
}
|
||||
|
||||
await this.disk.deleteAll(oldPath)
|
||||
}
|
||||
|
||||
public entryExists: StorageService['entryExists'] = async (path) => {
|
||||
const storagePath = this.pathService.toStoragePath(path)
|
||||
|
||||
if (await this.disk.exists(storagePath)) return true
|
||||
|
||||
return this.directoryExists(storagePath)
|
||||
}
|
||||
|
||||
private async directoryExists(path: string): Promise<boolean> {
|
||||
return this.disk.exists(this.directoryMarkerService.markerPath(path))
|
||||
}
|
||||
|
||||
private async getFileEntryFromStoragePath(
|
||||
storagePath: string,
|
||||
params: {
|
||||
name?: string
|
||||
path?: string
|
||||
url?: string
|
||||
} = {}
|
||||
): Promise<FileEntry> {
|
||||
const metadata = await this.disk.getMetaData(storagePath)
|
||||
const path = params.path ?? this.pathService.toClientPath(storagePath)
|
||||
const url = params.url ?? (await this.disk.getUrl(storagePath))
|
||||
|
||||
return {
|
||||
name: params.name ?? this.pathService.basename(path),
|
||||
type: 'file',
|
||||
size: metadata?.contentLength ?? 0,
|
||||
path,
|
||||
url,
|
||||
updatedAt: metadata.lastModified.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
public listDirectoryEntries: StorageService['listDirectoryEntries'] = async (path) => {
|
||||
const diskPath = this.pathService.toStoragePath(path)
|
||||
|
||||
const response = await this.disk.listAll(diskPath)
|
||||
|
||||
const entries = await Promise.all(
|
||||
[...response.objects].map(async (file) => {
|
||||
if (file.isFile && !this.directoryMarkerService.isMarkerName(file.name)) {
|
||||
const relativePath = this.pathService.toClientPath(file.key)
|
||||
|
||||
return this.getFileEntryFromStoragePath(file.key, {
|
||||
name: file.name,
|
||||
path: relativePath,
|
||||
url: await this.disk.getUrl(relativePath),
|
||||
})
|
||||
}
|
||||
|
||||
if (file.isDirectory) {
|
||||
return {
|
||||
name: file.name,
|
||||
type: 'folder',
|
||||
path: this.pathService.toClientPath(file.prefix),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
)
|
||||
|
||||
return entries.filter(Boolean) as Entry[]
|
||||
}
|
||||
|
||||
public listAllEntries: StorageService['listAllEntries'] = async (path) => {
|
||||
let response: Awaited<ReturnType<typeof this.disk.listAll>>
|
||||
|
||||
try {
|
||||
response = await this.disk.listAll(this.pathService.toStoragePath(path), { recursive: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
[...response.objects].map(async (item) => {
|
||||
if (item.isDirectory) return null
|
||||
|
||||
if (this.directoryMarkerService.isMarkerName(item.name)) {
|
||||
const folderPath = this.directoryMarkerService.directoryPathFromMarker(item.key)
|
||||
|
||||
return {
|
||||
name: this.pathService.basename(folderPath) || 'root',
|
||||
path: this.pathService.toClientPath(folderPath),
|
||||
type: 'folder',
|
||||
}
|
||||
}
|
||||
|
||||
return this.getFileEntryFromStoragePath(item.key, {
|
||||
name: item.name,
|
||||
path: this.pathService.toClientPath(item.key),
|
||||
url: '',
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return entries.filter(Boolean) as Entry[]
|
||||
}
|
||||
|
||||
public createDirectory: StorageService['createDirectory'] = async (path, name) => {
|
||||
const dirPath = this.pathService.join(this.pathService.toStoragePath(path), name)
|
||||
|
||||
const isExists = await this.directoryExists(dirPath)
|
||||
|
||||
if (isExists) throw new DirExistsException()
|
||||
|
||||
await this.disk.put(this.directoryMarkerService.markerPath(dirPath), '')
|
||||
|
||||
return { path: this.pathService.toClientPath(dirPath) }
|
||||
}
|
||||
|
||||
public deleteEntry: StorageService['deleteEntry'] = async (path) => {
|
||||
await this.disk.deleteAll(this.pathService.toStoragePath(path))
|
||||
return { path }
|
||||
}
|
||||
|
||||
public moveEntry: StorageService['moveEntry'] = async (path, newPath) => {
|
||||
const oldPath = this.pathService.toStoragePath(path)
|
||||
const targetPath = this.pathService.toStoragePath(newPath)
|
||||
const entry = await this.getEntry(path)
|
||||
|
||||
if (entry.type === 'file') {
|
||||
await this.disk.move(oldPath, targetPath)
|
||||
} else {
|
||||
await this.moveDirectory(oldPath, targetPath)
|
||||
}
|
||||
|
||||
return { path: this.pathService.toClientPath(targetPath) }
|
||||
}
|
||||
|
||||
public getFileEntry: StorageService['getFileEntry'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
return this.getFileEntryFromStoragePath(source, {
|
||||
name: this.pathService.basename(path),
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
public getFileContent: StorageService['getFileContent'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
return this.disk.get(source)
|
||||
}
|
||||
|
||||
public getFileStream: StorageService['getFileStream'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
const metadata = await this.disk.getMetaData(source)
|
||||
const stream = await this.disk.getStream(source)
|
||||
const name = this.pathService.basename(path)
|
||||
|
||||
return {
|
||||
stream,
|
||||
contentType: metadata.contentType,
|
||||
size: metadata.contentLength,
|
||||
name,
|
||||
}
|
||||
}
|
||||
|
||||
private async getDirectoryEntry(path: string): Promise<FolderEntry> {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const isExists = await this.directoryExists(source)
|
||||
|
||||
if (!isExists) throw new DirNotFoundException()
|
||||
|
||||
const name = this.pathService.basename(path) || 'root'
|
||||
|
||||
return { type: 'folder', name, path }
|
||||
}
|
||||
|
||||
public getEntry: StorageService['getEntry'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (exists) return this.getFileEntry(path)
|
||||
|
||||
const response = await this.disk.listAll(source)
|
||||
|
||||
if (Array.from(response.objects).length) return this.getDirectoryEntry(path)
|
||||
|
||||
throw new Error('Entry not found')
|
||||
}
|
||||
|
||||
public writeFile: StorageService['writeFile'] = async (path, file) => {
|
||||
const writePath = this.pathService.toStoragePath(path)
|
||||
await file.moveToDisk(writePath)
|
||||
|
||||
return { path: this.pathService.toClientPath(writePath) }
|
||||
}
|
||||
|
||||
public copyEntry: StorageService['copyEntry'] = async (sourcePath, targetPath) => {
|
||||
const source = this.pathService.toStoragePath(sourcePath)
|
||||
const target = this.pathService.toStoragePath(targetPath)
|
||||
await this.disk.copy(source, target)
|
||||
|
||||
return { path: this.pathService.toClientPath(target) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import drive from '@adonisjs/drive/services/main'
|
||||
import {
|
||||
DirExistsException,
|
||||
DirNotFoundException,
|
||||
FileNotFoundException,
|
||||
} from '#exceptions/file_manager_exeptions'
|
||||
import { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||
import { StorageService } from './storage_service_interface.js'
|
||||
import PathService from '#base_modules/FileManager/services/path_service'
|
||||
import FSDirectoryMarkerService from '#base_modules/FileManager/services/storage_service/fs_directory_marker_service'
|
||||
|
||||
export class S3StorageService implements StorageService {
|
||||
private disk = drive.use('s3')
|
||||
private pathService = new PathService()
|
||||
private directoryMarkerService = new FSDirectoryMarkerService()
|
||||
|
||||
private directoryPrefix(path: string): string {
|
||||
return path ? `${path.replace(/\/$/, '')}/` : path
|
||||
}
|
||||
|
||||
private async moveDirectory(oldPath: string, newPath: string) {
|
||||
const oldPrefix = this.directoryPrefix(oldPath)
|
||||
const response = await this.disk.listAll(oldPrefix, { recursive: true })
|
||||
|
||||
for (const item of response.objects) {
|
||||
if (!item.isFile) continue
|
||||
|
||||
const relative = item.key.slice(oldPrefix.length).replace(/^\/+/, '')
|
||||
const target = this.pathService.join(newPath, relative)
|
||||
const dir = target.split('/').slice(0, -1).join('/')
|
||||
|
||||
if (dir) await this.disk.put(this.directoryMarkerService.markerPath(dir), '')
|
||||
|
||||
await this.disk.move(item.key, target)
|
||||
}
|
||||
|
||||
await this.disk.deleteAll(oldPrefix)
|
||||
}
|
||||
|
||||
public entryExists: StorageService['entryExists'] = async (path) => {
|
||||
const storagePath = this.pathService.toStoragePath(path)
|
||||
|
||||
if (await this.disk.exists(storagePath)) return true
|
||||
|
||||
return this.directoryExists(storagePath)
|
||||
}
|
||||
|
||||
private async directoryExists(path: string): Promise<boolean> {
|
||||
return this.disk.exists(this.directoryMarkerService.markerPath(path))
|
||||
}
|
||||
|
||||
private async getFileEntryFromStoragePath(
|
||||
storagePath: string,
|
||||
params: {
|
||||
name?: string
|
||||
path?: string
|
||||
url?: string
|
||||
} = {}
|
||||
): Promise<FileEntry> {
|
||||
const metadata = await this.disk.getMetaData(storagePath)
|
||||
const path = params.path ?? this.pathService.toClientPath(storagePath)
|
||||
const url = params.url ?? (await this.disk.getUrl(storagePath))
|
||||
|
||||
return {
|
||||
name: params.name ?? this.pathService.basename(path),
|
||||
type: 'file',
|
||||
size: metadata?.contentLength ?? 0,
|
||||
path,
|
||||
url,
|
||||
updatedAt: metadata.lastModified.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
public listDirectoryEntries: StorageService['listDirectoryEntries'] = async (path) => {
|
||||
const diskPath = this.pathService.toStoragePath(path)
|
||||
const response = await this.disk.listAll(diskPath)
|
||||
|
||||
const entries = await Promise.all(
|
||||
[...response.objects].map(async (file) => {
|
||||
if (file.isFile && !this.directoryMarkerService.isMarkerName(file.name)) {
|
||||
const relativePath = this.pathService.toClientPath(file.key)
|
||||
|
||||
return this.getFileEntryFromStoragePath(file.key, {
|
||||
name: file.name,
|
||||
path: relativePath,
|
||||
url: await this.disk.getUrl(relativePath),
|
||||
})
|
||||
}
|
||||
|
||||
if (file.isDirectory) {
|
||||
return {
|
||||
name: file.name,
|
||||
type: 'folder',
|
||||
path: this.pathService.toClientPath(file.prefix),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
)
|
||||
|
||||
return entries.filter(Boolean) as Entry[]
|
||||
}
|
||||
|
||||
public listAllEntries: StorageService['listAllEntries'] = async (path) => {
|
||||
let response: Awaited<ReturnType<typeof this.disk.listAll>>
|
||||
|
||||
try {
|
||||
const storagePath = this.pathService.toStoragePath(path)
|
||||
response = await this.disk.listAll(this.directoryPrefix(storagePath), { recursive: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
[...response.objects].map(async (item) => {
|
||||
if (item.isDirectory) return null
|
||||
|
||||
if (this.directoryMarkerService.isMarkerName(item.name)) {
|
||||
const folderPath = this.directoryMarkerService.directoryPathFromMarker(item.key)
|
||||
|
||||
return {
|
||||
name: this.pathService.basename(folderPath) || 'root',
|
||||
path: this.pathService.toClientPath(folderPath),
|
||||
type: 'folder',
|
||||
}
|
||||
}
|
||||
|
||||
return this.getFileEntryFromStoragePath(item.key, {
|
||||
name: item.name,
|
||||
path: this.pathService.toClientPath(item.key),
|
||||
url: '',
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return entries.filter(Boolean) as Entry[]
|
||||
}
|
||||
|
||||
public createDirectory: StorageService['createDirectory'] = async (path, name) => {
|
||||
const dirPath = this.pathService.join(this.pathService.toStoragePath(path), name)
|
||||
|
||||
const isExists = await this.directoryExists(dirPath)
|
||||
|
||||
if (isExists) throw new DirExistsException()
|
||||
|
||||
await this.disk.put(this.directoryMarkerService.markerPath(dirPath), '')
|
||||
|
||||
return { path: this.pathService.toClientPath(dirPath) }
|
||||
}
|
||||
|
||||
public deleteEntry: StorageService['deleteEntry'] = async (path) => {
|
||||
const storagePath = this.pathService.toStoragePath(path)
|
||||
const entry = await this.getEntry(path)
|
||||
|
||||
if (entry.type === 'file') {
|
||||
await this.disk.delete(storagePath)
|
||||
} else {
|
||||
await this.disk.deleteAll(this.directoryPrefix(storagePath))
|
||||
}
|
||||
|
||||
return { path }
|
||||
}
|
||||
|
||||
public moveEntry: StorageService['moveEntry'] = async (path, newPath) => {
|
||||
const oldPath = this.pathService.toStoragePath(path)
|
||||
const targetPath = this.pathService.toStoragePath(newPath)
|
||||
const entry = await this.getEntry(path)
|
||||
|
||||
if (entry.type === 'file') {
|
||||
await this.disk.move(oldPath, targetPath)
|
||||
} else {
|
||||
await this.moveDirectory(oldPath, targetPath)
|
||||
}
|
||||
|
||||
return { path: this.pathService.toClientPath(targetPath) }
|
||||
}
|
||||
|
||||
public getFileEntry: StorageService['getFileEntry'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
return this.getFileEntryFromStoragePath(source, {
|
||||
name: this.pathService.basename(path),
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
public getFileContent: StorageService['getFileContent'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
return this.disk.get(source)
|
||||
}
|
||||
|
||||
public getFileStream: StorageService['getFileStream'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
const metadata = await this.disk.getMetaData(source)
|
||||
const stream = await this.disk.getStream(source)
|
||||
const name = this.pathService.basename(path)
|
||||
|
||||
return {
|
||||
stream,
|
||||
contentType: metadata.contentType,
|
||||
size: metadata.contentLength,
|
||||
name,
|
||||
}
|
||||
}
|
||||
|
||||
private async getDirectoryEntry(path: string): Promise<FolderEntry> {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const isExists = await this.directoryExists(source)
|
||||
|
||||
if (!isExists) throw new DirNotFoundException()
|
||||
|
||||
const name = this.pathService.basename(path) || 'root'
|
||||
|
||||
return { type: 'folder', name, path }
|
||||
}
|
||||
|
||||
public getEntry: StorageService['getEntry'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (exists) return this.getFileEntry(path)
|
||||
|
||||
const response = await this.disk.listAll(source)
|
||||
|
||||
if (Array.from(response.objects).length) return this.getDirectoryEntry(path)
|
||||
|
||||
throw new Error('Entry not found')
|
||||
}
|
||||
|
||||
public writeFile: StorageService['writeFile'] = async (path, file) => {
|
||||
const writePath = this.pathService.toStoragePath(path)
|
||||
|
||||
if (!file.tmpPath) {
|
||||
throw new Error('Cannot write multipart file to S3 without a temporary file path')
|
||||
}
|
||||
|
||||
await this.disk.putStream(writePath, createReadStream(file.tmpPath), {
|
||||
contentType: file.headers['content-type'],
|
||||
contentLength: file.size,
|
||||
})
|
||||
|
||||
return { path: this.pathService.toClientPath(writePath) }
|
||||
}
|
||||
|
||||
public copyEntry: StorageService['copyEntry'] = async (sourcePath, targetPath) => {
|
||||
const source = this.pathService.toStoragePath(sourcePath)
|
||||
const target = this.pathService.toStoragePath(targetPath)
|
||||
await this.disk.copy(source, target)
|
||||
|
||||
return { path: this.pathService.toClientPath(target) }
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { Readable } from 'node:stream'
|
||||
import { MultipartFile } from '@adonisjs/core/bodyparser'
|
||||
import { Entry, FileEntry } from '#base_modules/FileManager/types/entries'
|
||||
|
||||
export interface FileStream {
|
||||
stream: Readable
|
||||
contentType?: string
|
||||
size?: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface StorageService {
|
||||
listDirectoryEntries(path: string): Promise<Entry[]>
|
||||
listAllEntries(path: string): Promise<Entry[]>
|
||||
entryExists(path: string): Promise<boolean>
|
||||
getEntry(path: string): Promise<Entry>
|
||||
getFileEntry(path: string): Promise<FileEntry>
|
||||
getFileContent(path: string): Promise<string>
|
||||
getFileStream(path: string): Promise<FileStream>
|
||||
writeFile(path: string, file: MultipartFile): Promise<{ path: string }>
|
||||
createDirectory(path: string, name: string): Promise<{ path: string }>
|
||||
moveEntry(oldPath: string, newPath: string): Promise<{ path: string }>
|
||||
deleteEntry(path: string): Promise<{ path: string }>
|
||||
copyEntry(sourcePath: string, targetPath: string): Promise<{ path: string }>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
interface EntryBase {
|
||||
name: string
|
||||
path: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface FileEntry extends EntryBase {
|
||||
type: 'file'
|
||||
size?: number
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface FolderEntry extends EntryBase {
|
||||
type: 'folder'
|
||||
}
|
||||
|
||||
export type Entry = FileEntry | FolderEntry
|
||||
@@ -0,0 +1,11 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import { pathSchema, nameSchema } from './schemas.js'
|
||||
|
||||
const createDirectoryValidator = vine.compile(
|
||||
vine.object({
|
||||
name: nameSchema,
|
||||
path: pathSchema,
|
||||
})
|
||||
)
|
||||
|
||||
export default createDirectoryValidator
|
||||
@@ -0,0 +1,10 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import { pathSchema } from './schemas.js'
|
||||
|
||||
const duplicateEntriesValidator = vine.compile(
|
||||
vine.object({
|
||||
paths: vine.array(pathSchema).minLength(1).maxLength(3),
|
||||
})
|
||||
)
|
||||
|
||||
export default duplicateEntriesValidator
|
||||
@@ -0,0 +1,13 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
const listValidator = vine.compile(
|
||||
vine.object({
|
||||
filterBy: vine.enum(['media', 'archives', 'documents', 'all']),
|
||||
sortBy: vine.enum(['name', 'size', 'type', 'date']),
|
||||
sortOrder: vine.enum(['asc', 'desc']),
|
||||
maxResults: vine.number().max(100).optional(),
|
||||
paginationToken: vine.string().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export default listValidator
|
||||
@@ -0,0 +1,11 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import { pathSchema, nameSchema } from './schemas.js'
|
||||
|
||||
const renameEntryValidator = vine.compile(
|
||||
vine.object({
|
||||
name: nameSchema,
|
||||
path: pathSchema,
|
||||
})
|
||||
)
|
||||
|
||||
export default renameEntryValidator
|
||||
@@ -0,0 +1,13 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const nameSchema = vine
|
||||
.string()
|
||||
.trim()
|
||||
.minLength(1)
|
||||
.regex(/^[a-zA-Z0-9_\-. ]+$/)
|
||||
|
||||
export const pathSchema = vine
|
||||
.string()
|
||||
.trim()
|
||||
.minLength(1)
|
||||
.regex(/^[a-zA-Z0-9_\-. /]+$/)
|
||||
@@ -0,0 +1,13 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import { pathSchema } from './schemas.js'
|
||||
|
||||
const uploadFileValidator = vine.compile(
|
||||
vine.object({
|
||||
path: pathSchema,
|
||||
file: vine.file({
|
||||
size: '10mb',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
export default uploadFileValidator
|
||||
@@ -0,0 +1,9 @@
|
||||
import { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
import { getAllPermissions } from '#services/permission_service'
|
||||
|
||||
export default class PermissionsController {
|
||||
public async index({ response }: HttpContext) {
|
||||
return response.ok(await getAllPermissions())
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { BaseModel, column } from '@adonisjs/lucid/orm'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
export default class Permission extends BaseModel {
|
||||
static searchable = {
|
||||
string: ['name', 'slug'],
|
||||
number: ['id'],
|
||||
}
|
||||
@column({ isPrimary: true }) declare id: number
|
||||
@column() declare name: string
|
||||
@column() declare slug: string
|
||||
@column.dateTime({ columnName: 'created_at' }) declare createdAt: DateTime | null
|
||||
@column.dateTime({ columnName: 'updated_at' }) declare updatedAt: DateTime | null
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BasePolicy } from '@adonisjs/bouncer'
|
||||
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
|
||||
import Admins from '#base_modules/Admin/model/admin'
|
||||
import { getAdminPermissionSlugs } from '#services/permission_service'
|
||||
|
||||
export default class PermissionPolicy extends BasePolicy {
|
||||
async permissionList(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('permission.view')
|
||||
}
|
||||
|
||||
async viewPermission(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('permission.view')
|
||||
}
|
||||
|
||||
async createPermission(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('permission.create')
|
||||
}
|
||||
|
||||
async updatePermission(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('permission.update')
|
||||
}
|
||||
|
||||
async deletePermission(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('permission.delete')
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import router from '@adonisjs/core/services/router'
|
||||
import { middleware } from '#start/kernel'
|
||||
|
||||
const permissionController = () =>
|
||||
import('#base_modules/Permission/controller/permission_controller')
|
||||
|
||||
router.resource('/permission', permissionController).apiOnly().use('*', [middleware.auth()]) //.use(['store', 'update', 'destroy'], [middleware.auth()])
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const createPermissionValidator = vine.compile(
|
||||
vine.object({
|
||||
id: vine.number().optional(),
|
||||
name: vine.string().trim(),
|
||||
slug: vine.string().trim(),
|
||||
})
|
||||
)
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const updatePermissionValidator = vine.compile(
|
||||
vine.object({
|
||||
id: vine.number().optional(),
|
||||
name: vine.string().trim(),
|
||||
slug: vine.string().trim(),
|
||||
})
|
||||
)
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
export default [
|
||||
{
|
||||
slug: 'role.view',
|
||||
name: 'Role view',
|
||||
},
|
||||
{
|
||||
slug: 'role.create',
|
||||
name: 'Role create',
|
||||
},
|
||||
{
|
||||
slug: 'role.update',
|
||||
name: 'Role update',
|
||||
},
|
||||
{
|
||||
slug: 'role.delete',
|
||||
name: 'Role delete',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
import { HttpContext } from '@adonisjs/core/http'
|
||||
import { addSearchFilter, searchableColumns } from '#services/database_inspector'
|
||||
import Role from '#base_modules/Role/model/role'
|
||||
import RolePolicy from '#base_modules/Role/policy/role_policy'
|
||||
import { createRoleValidator } from '#base_modules/Role/validator/role_create_validator'
|
||||
import { updateRoleValidator } from '#base_modules/Role/validator/role_update_validator'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
|
||||
export default class RoleController {
|
||||
public async index({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(RolePolicy).authorize('roleList')
|
||||
const {
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
search = null,
|
||||
sortBy = 'id',
|
||||
sortOrder = 'desc',
|
||||
} = request.all()
|
||||
const query = Role.query()
|
||||
if (search) {
|
||||
const columns = Role.searchable || (await searchableColumns('admins'))
|
||||
addSearchFilter(query, columns, search)
|
||||
}
|
||||
const result = await query.orderBy(sortBy, sortOrder).paginate(page, perPage)
|
||||
return response.ok(result)
|
||||
}
|
||||
|
||||
public async store({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(RolePolicy).authorize('createRole')
|
||||
const { permissions, ...data } = await createRoleValidator.validate(request.all())
|
||||
const record = await Role.create(data)
|
||||
if (record) {
|
||||
// await record.related('permissions').sync(data.permissions)
|
||||
await this.updatePermissions(record, permissions)
|
||||
return response.created({ message: 'Successfully created.' })
|
||||
}
|
||||
return response.badRequest({ message: 'Cannot create.' })
|
||||
}
|
||||
|
||||
public async show({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(RolePolicy).authorize('viewRole')
|
||||
const { id } = request.params()
|
||||
if (!id) return response.notFound({ message: 'Not found.' })
|
||||
const record = await Role.find(id)
|
||||
if (!record) return response.notFound({ message: 'Not found.' })
|
||||
const permissions = await db
|
||||
.from('role_permissions')
|
||||
.where('role_id', id)
|
||||
.select(['permission_slug as slug', 'role_id'])
|
||||
return response.ok({ ...record.serialize(), permissions })
|
||||
}
|
||||
|
||||
public async update({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(RolePolicy).authorize('updateRole')
|
||||
const { id } = request.params()
|
||||
if (!id) return response.notFound({ message: 'Not found.' })
|
||||
const data = await updateRoleValidator.validate(request.all())
|
||||
const record = await Role.find(id)
|
||||
if (!record) return response.notFound({ message: 'Not found.' })
|
||||
record.name = data.name
|
||||
await record.save()
|
||||
await this.updatePermissions(record, data.permissions)
|
||||
return response.ok({ message: 'Updated.' })
|
||||
}
|
||||
|
||||
public async destroy({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(RolePolicy).authorize('deleteRole')
|
||||
const { id } = request.params()
|
||||
if (!id) return response.notFound({ message: 'Not found.' })
|
||||
const record = await Role.find(id)
|
||||
if (!record) return response.notFound({ message: 'Not found.' })
|
||||
await Role.query().where('id', id).delete()
|
||||
return response.ok({ message: 'Deleted.' })
|
||||
}
|
||||
|
||||
private async updatePermissions(record: Role, permissions?: string[]) {
|
||||
await db.from('role_permissions').where('role_id', record.id).delete()
|
||||
if (!permissions || permissions.length === 0) {
|
||||
console.log('no permissions to insert')
|
||||
return
|
||||
}
|
||||
const newPermissions = permissions.map((perm) => ({
|
||||
role_id: record.id,
|
||||
permission_slug: perm,
|
||||
}))
|
||||
await db.table('role_permissions').insert(newPermissions)
|
||||
console.log('inserted permissions:', newPermissions.length)
|
||||
}
|
||||
}
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
import { BaseModel, column } from '@adonisjs/lucid/orm'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
export default class Role extends BaseModel {
|
||||
static searchable = {
|
||||
string: ['name'],
|
||||
number: ['id'],
|
||||
}
|
||||
@column({ isPrimary: true }) declare id: number
|
||||
@column() declare name: string
|
||||
@column.dateTime({ columnName: 'created_at' }) declare createdAt: DateTime | null
|
||||
@column.dateTime({ columnName: 'updated_at' }) declare updatedAt: DateTime | null
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { BasePolicy } from '@adonisjs/bouncer'
|
||||
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
|
||||
import Admins from '#base_modules/Admin/model/admin'
|
||||
import { getAdminPermissionSlugs } from '#services/permission_service'
|
||||
|
||||
export default class RolePolicy extends BasePolicy {
|
||||
async roleList(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('role.view')
|
||||
}
|
||||
|
||||
async viewRole(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('role.view')
|
||||
}
|
||||
|
||||
async createRole(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('role.create')
|
||||
}
|
||||
|
||||
async updateRole(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('role.update')
|
||||
}
|
||||
|
||||
async deleteRole(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('role.delete')
|
||||
}
|
||||
}
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
import router from '@adonisjs/core/services/router'
|
||||
import { middleware } from '#start/kernel'
|
||||
|
||||
const roleController = () => import('#base_modules/Role/controller/role_controller')
|
||||
|
||||
router.resource('/role', roleController).apiOnly().use('*', [middleware.auth()]) //.use(['store', 'update', 'destroy'], [middleware.auth()])
|
||||
@@ -0,0 +1,9 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const createRoleValidator = vine.compile(
|
||||
vine.object({
|
||||
id: vine.number().optional(),
|
||||
name: vine.string().trim(),
|
||||
permissions: vine.array(vine.string()),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const updateRoleValidator = vine.compile(
|
||||
vine.object({
|
||||
id: vine.number().optional(),
|
||||
name: vine.string().trim(),
|
||||
permissions: vine.array(vine.string()),
|
||||
})
|
||||
)
|
||||
Executable
+482
@@ -0,0 +1,482 @@
|
||||
import { HttpContext } from '@adonisjs/core/http'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, cpSync } from 'node:fs'
|
||||
import { modelToSchema } from '#services/swagger_model'
|
||||
import handlebars from 'handlebars'
|
||||
import {
|
||||
getDatabaseTables,
|
||||
getColumnsForTable,
|
||||
getForeignKeysReferencing,
|
||||
} from '#services/database_inspector'
|
||||
|
||||
handlebars.registerHelper('eq', (a, b) => a === b)
|
||||
handlebars.registerHelper('not', (v) => !v)
|
||||
handlebars.registerHelper('or', (a, b) => a || b)
|
||||
handlebars.registerHelper('and', (a, b) => a && b)
|
||||
|
||||
function renderTemplate(templatePath: string, context: any): string {
|
||||
const raw = readFileSync(templatePath, 'utf8')
|
||||
const template = handlebars.compile(raw)
|
||||
return template(context)
|
||||
}
|
||||
|
||||
export default class MakeModuleController {
|
||||
capitalize(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
|
||||
camelCase(str: string) {
|
||||
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
pascalCase(str: string) {
|
||||
return this.capitalize(this.camelCase(str))
|
||||
}
|
||||
|
||||
snakeCase(str: string) {
|
||||
return str.slice(0, str.length - 1)
|
||||
}
|
||||
|
||||
mapDBTypeToTs(type: string): string {
|
||||
if (type.includes('int')) return 'number'
|
||||
if (type === 'boolean') return 'boolean'
|
||||
if (type.includes('timestamp') || type === 'date') return 'DateTime'
|
||||
return 'string'
|
||||
}
|
||||
|
||||
mapDBTypeToValidator(type: string): string {
|
||||
if (
|
||||
type.includes('int') ||
|
||||
type === 'numeric' ||
|
||||
type === 'decimal' ||
|
||||
type === 'real' ||
|
||||
type === 'double precision'
|
||||
) {
|
||||
return 'number'
|
||||
}
|
||||
|
||||
if (type === 'boolean') return 'boolean'
|
||||
if (type.includes('timestamp') || type === 'date') return 'date'
|
||||
return 'string'
|
||||
}
|
||||
|
||||
mapDBTypeToFrontend(type: string): string {
|
||||
if (type.includes('int') || type === 'numeric' || type === 'bigint') return 'number'
|
||||
if (type === 'boolean') return 'boolean'
|
||||
if (type.includes('timestamp') || type === 'date') return 'IsoDateString'
|
||||
return 'string'
|
||||
}
|
||||
|
||||
async generateRelationships(table: string) {
|
||||
const lines: string[] = []
|
||||
const imports: Set<string> = new Set()
|
||||
|
||||
const columns = await getColumnsForTable(table)
|
||||
const references = await getForeignKeysReferencing(table)
|
||||
|
||||
for (const col of columns) {
|
||||
if (col.column_name.endsWith('_id')) {
|
||||
const refTable = col.column_name.replace('_id', '')
|
||||
const refModel = this.capitalize(refTable)
|
||||
imports.add('belongsTo')
|
||||
lines.push(
|
||||
` @belongsTo(() => ${refModel})\n declare ${refTable}: BelongsTo<typeof ${refModel}>`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const ref of references) {
|
||||
const relModel = this.capitalize(ref.table_name)
|
||||
imports.add('hasMany')
|
||||
lines.push(
|
||||
` @hasMany(() => ${relModel})\n declare ${ref.table_name}: HasMany<typeof ${relModel}>`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
imports: Array.from(imports),
|
||||
code: lines,
|
||||
}
|
||||
}
|
||||
|
||||
async tables({ response }: HttpContext) {
|
||||
const tables = await getDatabaseTables()
|
||||
return response.ok(tables)
|
||||
}
|
||||
|
||||
async generate({ request, response }: HttpContext) {
|
||||
const {
|
||||
name,
|
||||
table,
|
||||
generate,
|
||||
form,
|
||||
formDefinition,
|
||||
dataTable,
|
||||
list,
|
||||
formDialog,
|
||||
menu,
|
||||
module,
|
||||
} = request.only([
|
||||
'name',
|
||||
'table',
|
||||
'generate',
|
||||
'form',
|
||||
'formDefinition',
|
||||
'dataTable',
|
||||
'list',
|
||||
'formDialog',
|
||||
'menu',
|
||||
'module',
|
||||
])
|
||||
|
||||
const appBase = process.cwd()
|
||||
const backendTplDir = join(appBase, 'app/templates/backend')
|
||||
const apiTplDir = join(appBase, 'app/templates/backend/api')
|
||||
const moduleName = this.capitalize(name)
|
||||
const modulePath = join(appBase, 'modules', moduleName)
|
||||
|
||||
const snake = this.snakeCase(table)
|
||||
const clientBase = join(appBase, '..', 'apps', 'admin', 'src', 'modules', moduleName)
|
||||
const templateModuleDir = join(appBase, '..', 'apps', 'admin', 'src', 'modules', '_template')
|
||||
|
||||
const paths = {
|
||||
// backend
|
||||
controller: join(modulePath, 'controller', `${snake}_controller.ts`),
|
||||
model: join(modulePath, 'model', `${snake}.ts`),
|
||||
// validatorCreate: join('app', 'validators', `${snake}_create_validator.ts`),
|
||||
// validatorUpdate: join('app', 'validators', `${snake}_update_validator.ts`),
|
||||
validatorCreate: join(modulePath, 'validators', `${snake}_create_validator.ts`),
|
||||
validatorUpdate: join(modulePath, 'validators', `${snake}_update_validator.ts`),
|
||||
routes: join(modulePath, 'routes.ts'),
|
||||
policy: join(modulePath, 'policy', `${snake}_policy.ts`),
|
||||
policiesMain: join(appBase, 'app', 'policies', 'main.ts'),
|
||||
permissionsConfig: join(modulePath, 'config', 'permissions.ts'),
|
||||
|
||||
// API subtree
|
||||
apiController: join(modulePath, 'api', 'controllers', `${snake}_api_controller.ts`),
|
||||
// apiValidatorCreate: join('app', 'validators', `${snake}_create_api_validator.ts`),
|
||||
// apiValidatorUpdate: join('app', 'validators', `${snake}_update_api_validator.ts`),
|
||||
apiValidatorCreate: join(modulePath, 'api', 'validators', `${snake}_create_api_validator.ts`),
|
||||
apiValidatorUpdate: join(modulePath, 'api', 'validators', `${snake}_update_api_validator.ts`),
|
||||
apiRoutes: join(modulePath, 'api', 'routes.ts'),
|
||||
|
||||
// tests
|
||||
testController: join(
|
||||
appBase,
|
||||
'tests',
|
||||
'modules',
|
||||
moduleName,
|
||||
`${this.snakeCase(table)}.spec.ts`
|
||||
),
|
||||
testApi: join(
|
||||
appBase,
|
||||
'tests',
|
||||
'modules',
|
||||
moduleName,
|
||||
`${this.snakeCase(table)}.api.spec.ts`
|
||||
),
|
||||
|
||||
// frontend
|
||||
clientModule: clientBase,
|
||||
clientForm: join(clientBase, 'view', 'forms', 'MainForm', 'Component.tsx'),
|
||||
clientFormDefinition: join(clientBase, 'view', 'forms', 'MainForm', 'form.ts'),
|
||||
clientDatatable: join(clientBase, 'view', 'widgets', 'DataTable.tsx'),
|
||||
clientList: join(clientBase, 'pages', 'ListPage', 'index.tsx'),
|
||||
clientDialog: join(clientBase, 'view', 'widgets', 'ManageTrigger.tsx'),
|
||||
clientConfig: join(clientBase, 'config', 'meta.ts'),
|
||||
clientMenu: join(clientBase, 'config', 'menu.ts'),
|
||||
clientModuleConfig: join(clientBase, 'config', 'module.ts'),
|
||||
clientEntity: join(clientBase, 'entities', 'Entity.ts'),
|
||||
}
|
||||
|
||||
if (existsSync(templateModuleDir)) {
|
||||
cpSync(templateModuleDir, paths.clientModule, { recursive: true })
|
||||
}
|
||||
|
||||
Object.values(paths).forEach((filePath) => {
|
||||
mkdirSync(dirname(filePath), { recursive: true })
|
||||
})
|
||||
|
||||
const columns = await getColumnsForTable(table)
|
||||
const modelClass = this.capitalize(table)
|
||||
const relationship = await this.generateRelationships(table)
|
||||
|
||||
const enhancedColumns = columns.map((col) => {
|
||||
const colName = col.column_name
|
||||
const camelName = this.camelCase(colName)
|
||||
const type = this.mapDBTypeToTs(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : '')
|
||||
return {
|
||||
name: colName,
|
||||
camelCase: camelName,
|
||||
type,
|
||||
isPrimary: colName === 'id',
|
||||
isPassword: colName === 'password',
|
||||
isEmail: colName === 'email',
|
||||
isDateTime: ['created_at', 'updated_at'].includes(colName),
|
||||
isSpecial: ['created_at', 'updated_at', 'deleted_at'].includes(colName),
|
||||
isRequired: col.is_nullable === 'NO',
|
||||
isNullable: col.is_nullable === 'YES',
|
||||
validatorType: this.mapDBTypeToValidator(col.data_type),
|
||||
needsTrim:
|
||||
this.mapDBTypeToValidator(col.data_type) === 'string' &&
|
||||
colName !== 'email' &&
|
||||
colName !== 'password',
|
||||
}
|
||||
})
|
||||
|
||||
const sampleFor = (col: any) => {
|
||||
const t = col.validatorType
|
||||
if (t === 'number') return 1
|
||||
if (t === 'boolean') return false
|
||||
if (t === 'date') return `'${new Date().toISOString()}'`
|
||||
return `'sample_${col.camelCase}'`
|
||||
}
|
||||
|
||||
const createFields = enhancedColumns
|
||||
.filter((c) => !c.isPrimary && !c.isSpecial)
|
||||
.map((c) => ({
|
||||
camelCase: c.camelCase,
|
||||
sampleValue: sampleFor(c),
|
||||
column_name: c.name,
|
||||
}))
|
||||
|
||||
const updateFields = enhancedColumns
|
||||
.filter((c) => !c.isPrimary && !c.isDateTime)
|
||||
.map((c) => ({
|
||||
camelCase: c.camelCase,
|
||||
sampleValue: sampleFor(c),
|
||||
column_name: c.name,
|
||||
}))
|
||||
|
||||
// minimal insert json for show/update/destroy fixtures
|
||||
const insertJson = `{
|
||||
${createFields.map((f) => ` ${f.column_name}: ${f.sampleValue}`).join(',\n')}
|
||||
}`
|
||||
|
||||
const frontendFields = columns.map((col) => {
|
||||
return {
|
||||
name: col.column_name,
|
||||
type:
|
||||
this.mapDBTypeToFrontend(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : ''),
|
||||
}
|
||||
})
|
||||
|
||||
// FRONTEND writer
|
||||
writeFileSync(paths.clientForm, form ?? '', 'utf8')
|
||||
writeFileSync(paths.clientFormDefinition, formDefinition ?? '', 'utf8')
|
||||
writeFileSync(paths.clientDatatable, dataTable ?? '', 'utf8')
|
||||
writeFileSync(paths.clientList, list ?? '', 'utf8')
|
||||
writeFileSync(paths.clientDialog, formDialog ?? '', 'utf8')
|
||||
writeFileSync(paths.clientMenu, menu ?? '', 'utf8')
|
||||
writeFileSync(paths.clientModuleConfig, module ?? '', 'utf8')
|
||||
|
||||
const configContent = renderTemplate(join(backendTplDir, '..', 'frontend', 'config.hbs'), {
|
||||
name,
|
||||
})
|
||||
writeFileSync(paths.clientConfig, configContent)
|
||||
|
||||
const entityContent = renderTemplate(join(backendTplDir, '..', 'frontend', 'entity.hbs'), {
|
||||
fields: frontendFields,
|
||||
})
|
||||
|
||||
writeFileSync(paths.clientEntity, entityContent)
|
||||
|
||||
// BACKEND: model
|
||||
if (generate.includes('model')) {
|
||||
const content = renderTemplate(join(backendTplDir, 'model.hbs'), {
|
||||
name,
|
||||
pascalCaseName: this.pascalCase(name),
|
||||
capName: this.capitalize(name),
|
||||
className: modelClass,
|
||||
imports: relationship.imports.join(', '),
|
||||
columns: enhancedColumns,
|
||||
relationships: relationship.code,
|
||||
hasPassword: columns.some((col) => col.column_name === 'password'),
|
||||
})
|
||||
await writeFileSync(paths.model, content)
|
||||
}
|
||||
|
||||
// BACKEND: controller
|
||||
if (generate.includes('controller')) {
|
||||
const content = renderTemplate(join(backendTplDir, 'controller.hbs'), {
|
||||
pascalCaseName: this.pascalCase(name),
|
||||
modelName: modelClass,
|
||||
capName: this.capitalize(name),
|
||||
moduleName,
|
||||
tableName: table,
|
||||
snakeName: snake,
|
||||
name,
|
||||
})
|
||||
writeFileSync(paths.controller, content)
|
||||
|
||||
const testsTplDir = join(backendTplDir, 'tests')
|
||||
|
||||
// controller tests
|
||||
const ctrlSpec = renderTemplate(join(testsTplDir, 'controller.spec.hbs'), {
|
||||
pascalCaseName: this.pascalCase(name),
|
||||
snakeCaseName: this.snakeCase(table),
|
||||
tableName: table,
|
||||
createFields,
|
||||
updateFields,
|
||||
insertJson,
|
||||
})
|
||||
mkdirSync(dirname(paths.testController), { recursive: true })
|
||||
writeFileSync(paths.testController, ctrlSpec, 'utf8')
|
||||
}
|
||||
|
||||
// BACKEND: routes
|
||||
if (generate.includes('routes')) {
|
||||
const content = renderTemplate(join(backendTplDir, 'routes.hbs'), {
|
||||
name,
|
||||
capName: this.capitalize(name),
|
||||
className: moduleName,
|
||||
routeName: name,
|
||||
snakeCaseName: snake,
|
||||
})
|
||||
writeFileSync(paths.routes, content)
|
||||
}
|
||||
|
||||
// BACKEND: validators
|
||||
if (generate.includes('validator')) {
|
||||
const createContent = renderTemplate(join(backendTplDir, 'validator.hbs'), {
|
||||
className: moduleName,
|
||||
functionName: `create${moduleName}Validator`,
|
||||
fields: enhancedColumns.filter((col) => !col.isPrimary && !col.isSpecial),
|
||||
})
|
||||
writeFileSync(paths.validatorCreate, createContent)
|
||||
|
||||
const updateContent = renderTemplate(join(backendTplDir, 'validator.hbs'), {
|
||||
className: moduleName,
|
||||
functionName: `update${moduleName}Validator`,
|
||||
fields: enhancedColumns.filter((col) => !col.isPrimary && !col.isDateTime),
|
||||
})
|
||||
writeFileSync(paths.validatorUpdate, updateContent)
|
||||
}
|
||||
|
||||
// BACKEND: policy + permissions
|
||||
if (generate.includes('policy')) {
|
||||
const policyTpl = join(backendTplDir, 'policy.hbs')
|
||||
const base = name.toLowerCase()
|
||||
const slugBase = base.endsWith('s') ? base : `${base}s`
|
||||
const policyContent = renderTemplate(policyTpl, {
|
||||
name,
|
||||
slugBase,
|
||||
moduleName,
|
||||
modelName: modelClass,
|
||||
pascalCaseName: this.pascalCase(name),
|
||||
})
|
||||
writeFileSync(paths.policy, policyContent)
|
||||
|
||||
if (!existsSync(paths.policiesMain)) {
|
||||
mkdirSync(dirname(paths.policiesMain), { recursive: true })
|
||||
writeFileSync(paths.policiesMain, `export const policies = {}\n`)
|
||||
}
|
||||
const capName = this.capitalize(name)
|
||||
const key = `${modelClass}Policy`
|
||||
const importLine = ` ${key}: () => import('#modules/${capName}/policy/${name}_policy'),`
|
||||
const current = readFileSync(paths.policiesMain, 'utf8')
|
||||
|
||||
if (current.includes('export const policies =')) {
|
||||
if (!current.includes(importLine)) {
|
||||
const updated = current.replace(
|
||||
/export const policies\s*=\s*\{\s*/m,
|
||||
(m) => m + '\n' + importLine + '\n'
|
||||
)
|
||||
writeFileSync(paths.policiesMain, updated)
|
||||
}
|
||||
} else {
|
||||
writeFileSync(paths.policiesMain, `export const policies = {\n${importLine}\n}\n`)
|
||||
}
|
||||
|
||||
const labelBase = this.capitalize(name.endsWith('s') ? name.slice(0, -1) : name)
|
||||
const perms = [
|
||||
{ action: 'view', slug: `${name}s.view`, name: `${labelBase} view` },
|
||||
{ action: 'create', slug: `${name}s.create`, name: `${labelBase} create` },
|
||||
{ action: 'update', slug: `${name}s.update`, name: `${labelBase} update` },
|
||||
{ action: 'delete', slug: `${name}s.delete`, name: `${labelBase} delete` },
|
||||
]
|
||||
|
||||
const fileContents =
|
||||
'export default [\n' +
|
||||
perms.map((p) => ` {\n slug: '${p.slug}',\n name: '${p.name}',\n },`).join('\n') +
|
||||
'\n]\n'
|
||||
|
||||
if (!existsSync(paths.permissionsConfig)) {
|
||||
mkdirSync(dirname(paths.permissionsConfig), { recursive: true })
|
||||
writeFileSync(paths.permissionsConfig, fileContents, 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
// API subtree
|
||||
if (generate.includes('api')) {
|
||||
const { default: genModel } = await import(paths.model)
|
||||
const test = await modelToSchema(genModel)
|
||||
console.warn(test)
|
||||
const apiCtx = {
|
||||
pascalCaseName: this.pascalCase(name),
|
||||
modelName: modelClass,
|
||||
capName: this.capitalize(name),
|
||||
moduleName,
|
||||
tableName: table,
|
||||
snakeCaseName: snake,
|
||||
name,
|
||||
snake,
|
||||
swaggerModel: await modelToSchema(genModel),
|
||||
}
|
||||
|
||||
// Controller
|
||||
const apiControllerTpl = join(apiTplDir, 'controller.hbs')
|
||||
const apiControllerContent = renderTemplate(apiControllerTpl, apiCtx)
|
||||
writeFileSync(paths.apiController, apiControllerContent, 'utf8')
|
||||
|
||||
// test
|
||||
const testsTplDir = join(backendTplDir, 'tests')
|
||||
const apiSpec = renderTemplate(join(testsTplDir, 'api.spec.hbs'), {
|
||||
snakeCaseName: this.snakeCase(table),
|
||||
createFields,
|
||||
})
|
||||
writeFileSync(paths.testApi, apiSpec, 'utf8')
|
||||
|
||||
// Validators
|
||||
const apiValidatorTpl = join(apiTplDir, 'validator.hbs')
|
||||
const apiCreateValidatorContent = renderTemplate(apiValidatorTpl, {
|
||||
...apiCtx,
|
||||
className: moduleName,
|
||||
functionName: `create${moduleName}ApiValidator`,
|
||||
fields: enhancedColumns.filter((c) => !c.isPrimary && !c.isSpecial),
|
||||
})
|
||||
writeFileSync(paths.apiValidatorCreate, apiCreateValidatorContent, 'utf8')
|
||||
|
||||
const apiUpdateValidatorContent = renderTemplate(apiValidatorTpl, {
|
||||
...apiCtx,
|
||||
className: moduleName,
|
||||
functionName: `update${moduleName}ApiValidator`,
|
||||
fields: enhancedColumns.filter((c) => !c.isPrimary && !c.isDateTime),
|
||||
})
|
||||
writeFileSync(paths.apiValidatorUpdate, apiUpdateValidatorContent, 'utf8')
|
||||
|
||||
// Routes
|
||||
const apiRoutesTpl = join(apiTplDir, 'routes.hbs')
|
||||
const apiRoutesContent = renderTemplate(apiRoutesTpl, apiCtx)
|
||||
writeFileSync(paths.apiRoutes, apiRoutesContent, 'utf8')
|
||||
}
|
||||
|
||||
return response.ok({ message: `Module "${moduleName}" created based on table "${table}"` })
|
||||
}
|
||||
|
||||
public async columns({ request, response }: HttpContext) {
|
||||
const { table } = request.params()
|
||||
const columns = await getColumnsForTable(table)
|
||||
const enhancedColumns = columns.map((col) => {
|
||||
const colName = col.column_name
|
||||
const type = this.mapDBTypeToTs(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : '')
|
||||
return {
|
||||
name: colName,
|
||||
type,
|
||||
isDateTime: ['created_at', 'updated_at'].includes(colName),
|
||||
}
|
||||
})
|
||||
|
||||
return response.ok({ columns: enhancedColumns })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Exception } from '@adonisjs/core/exceptions'
|
||||
|
||||
export class FilenameMissingExtensionException extends Exception {
|
||||
static status = 400
|
||||
static code = 'E_FILE_NAME_MISSING_EXTENSION'
|
||||
|
||||
constructor(message: string = 'File name missing extension') {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export class EntryNameNotChangedException extends Exception {
|
||||
static status = 400
|
||||
static code = 'E_ENTRY_NAME_NOT_CHANGED'
|
||||
|
||||
constructor(message: string = 'Entry name was not changed') {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export class EntryExistsException extends Exception {
|
||||
static status = 400
|
||||
static code = 'E_ENTRY_ALREADY_EXISTS'
|
||||
|
||||
constructor(message: string = 'Entry already exists') {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export class DirExistsException extends Exception {
|
||||
static status = 400
|
||||
static code = 'E_DIRECTORY_ALREADY_EXISTS'
|
||||
|
||||
constructor(message: string = 'Directory already exists') {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export class FileNotFoundException extends Exception {
|
||||
static status = 404
|
||||
static code = 'E_FILE_NOT_FOUND'
|
||||
|
||||
constructor(message: string = 'File not found') {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export class DirNotFoundException extends Exception {
|
||||
static status = 404
|
||||
static code = 'E_DIR_NOT_FOUND'
|
||||
|
||||
constructor(message: string = 'Directory not found') {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
|
||||
import { Exception } from '@adonisjs/core/exceptions'
|
||||
|
||||
export default class HttpExceptionHandler extends ExceptionHandler {
|
||||
/**
|
||||
* In debug mode, the exception handler will display verbose errors
|
||||
* with pretty printed stack traces.
|
||||
*/
|
||||
protected debug = !app.inProduction
|
||||
|
||||
/**
|
||||
* The method is used for handling errors and returning
|
||||
* response to the client
|
||||
*/
|
||||
async handle(error: unknown, ctx: HttpContext) {
|
||||
if (error instanceof Exception && error.code === 'E_FILE_NAME_MISSING_EXTENSION') {
|
||||
return ctx.response.status(error.status).send({
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
})
|
||||
}
|
||||
if (error instanceof Exception && error.code === 'E_ENTRY_NAME_NOT_CHANGED') {
|
||||
return ctx.response.status(error.status).send({
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
})
|
||||
}
|
||||
if (error instanceof Exception && error.code === 'E_ENTRY_ALREADY_EXISTS') {
|
||||
return ctx.response.status(error.status).send({
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
})
|
||||
}
|
||||
if (error instanceof Exception && error.code === 'E_DIRECTORY_ALREADY_EXISTS') {
|
||||
return ctx.response.status(error.status).send({
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
})
|
||||
}
|
||||
if (error instanceof Exception && error.code === 'E_FILE_NOT_FOUND') {
|
||||
return ctx.response.status(error.status).send({
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
})
|
||||
}
|
||||
if (error instanceof Exception && error.code === 'E_DIR_NOT_FOUND') {
|
||||
return ctx.response.status(error.status).send({
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
})
|
||||
}
|
||||
|
||||
return super.handle(error, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* The method is used to report error to the logging service or
|
||||
* the third party error monitoring service.
|
||||
*
|
||||
* @note You should not attempt to send a response from this method.
|
||||
*/
|
||||
async report(error: unknown, ctx: HttpContext) {
|
||||
return super.report(error, ctx)
|
||||
}
|
||||
}
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
interface Message422 {
|
||||
message: string
|
||||
rule: string
|
||||
field: string
|
||||
}
|
||||
export interface Error422 {
|
||||
errors: Message422[]
|
||||
}
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import type { NextFn } from '@adonisjs/core/types/http'
|
||||
import type { Authenticators } from '@adonisjs/auth/types'
|
||||
|
||||
/**
|
||||
* Auth middleware is used authenticate HTTP requests and deny
|
||||
* access to unauthenticated users.
|
||||
*/
|
||||
export default class AuthMiddleware {
|
||||
/**
|
||||
* The URL to redirect to, when authentication fails
|
||||
*/
|
||||
redirectTo = '/login'
|
||||
|
||||
async handle(
|
||||
ctx: HttpContext,
|
||||
next: NextFn,
|
||||
options: {
|
||||
guards?: (keyof Authenticators)[]
|
||||
} = {}
|
||||
) {
|
||||
await ctx.auth.authenticateUsing(options.guards, { loginRoute: this.redirectTo })
|
||||
return next()
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { Logger } from '@adonisjs/core/logger'
|
||||
import { HttpContext } from '@adonisjs/core/http'
|
||||
import type { NextFn } from '@adonisjs/core/types/http'
|
||||
|
||||
/**
|
||||
* The container bindings middleware binds classes to their request
|
||||
* specific value using the container resolver.
|
||||
*
|
||||
* - We bind "HttpContext" class to the "ctx" object
|
||||
* - And bind "Logger" class to the "ctx.logger" object
|
||||
*/
|
||||
export default class ContainerBindingsMiddleware {
|
||||
handle(ctx: HttpContext, next: NextFn) {
|
||||
ctx.containerResolver.bindValue(HttpContext, ctx)
|
||||
ctx.containerResolver.bindValue(Logger, ctx.logger)
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import type { NextFn } from '@adonisjs/core/types/http'
|
||||
/**
|
||||
* Updating the "Accept" header to always accept "application/json" response
|
||||
* from the server. This will force the internals of the framework like
|
||||
* validator errors or auth errors to return a JSON response.
|
||||
*/
|
||||
export default class ForceJsonResponseMiddleware {
|
||||
async handle({ request }: HttpContext, next: NextFn) {
|
||||
const headers = request.headers()
|
||||
headers.accept = 'application/json'
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { policies } from '#policies/main'
|
||||
import * as abilities from '#abilities/main'
|
||||
|
||||
import { Bouncer } from '@adonisjs/bouncer'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import type { NextFn } from '@adonisjs/core/types/http'
|
||||
|
||||
/**
|
||||
* Init bouncer middleware is used to create a bouncer instance
|
||||
* during an HTTP request
|
||||
*/
|
||||
export default class InitializeBouncerMiddleware {
|
||||
async handle(ctx: HttpContext, next: NextFn) {
|
||||
/**
|
||||
* Create bouncer instance for the ongoing HTTP request.
|
||||
* We will pull the user from the HTTP context.
|
||||
*/
|
||||
ctx.bouncer = new Bouncer(
|
||||
() => ctx.auth.user || null,
|
||||
abilities,
|
||||
policies
|
||||
).setContainerResolver(ctx.containerResolver)
|
||||
|
||||
/**
|
||||
* Share bouncer helpers with Edge templates.
|
||||
*/
|
||||
// if ('view' in ctx) {
|
||||
// ctx.view?.share(ctx.bouncer.edgeHelpers)
|
||||
// }
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@adonisjs/core/http' {
|
||||
export interface HttpContext {
|
||||
bouncer: Bouncer<
|
||||
Exclude<HttpContext['auth']['user'], undefined>,
|
||||
typeof abilities,
|
||||
typeof policies
|
||||
>
|
||||
}
|
||||
}
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Bouncer policies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may define a collection of policies inside this file and pre-register
|
||||
| them when creating a new bouncer instance.
|
||||
|
|
||||
| Pre-registered policies and abilities can be referenced as a string by their
|
||||
| name. Also they are must if want to perform authorization inside Edge
|
||||
| templates.
|
||||
|
|
||||
*/
|
||||
|
||||
export const policies = {
|
||||
AdminsPolicy: () => import('#base_modules/Admin/policy/admin_policy'),
|
||||
}
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'
|
||||
|
||||
export async function getDatabaseTables(): Promise<string[]> {
|
||||
const result = await db
|
||||
.from('information_schema.tables')
|
||||
.select('table_name')
|
||||
.where('table_schema', 'public')
|
||||
.whereNotIn('table_name', [
|
||||
'adonis_schema',
|
||||
'adonis_schema_versions',
|
||||
'auth_access_tokens',
|
||||
'admins',
|
||||
'permissions',
|
||||
'roles',
|
||||
'admin_roles',
|
||||
'role_permissions',
|
||||
'migrations',
|
||||
'migrations_lock',
|
||||
'pg_stat_statements',
|
||||
'pg_stat_activity',
|
||||
'pg_type',
|
||||
'pg_enum',
|
||||
'pg_class',
|
||||
'pg_attribute',
|
||||
'pg_index',
|
||||
'pg_namespace',
|
||||
'pg_tables',
|
||||
])
|
||||
.whereRaw(`table_name NOT LIKE 'pg_%'`)
|
||||
.whereRaw(`table_name NOT LIKE 'sql_%'`)
|
||||
|
||||
return result.map((row) => row.table_name)
|
||||
}
|
||||
|
||||
export async function getColumnsForTable(table: string) {
|
||||
const rows = await db
|
||||
.from('information_schema.columns')
|
||||
.select('column_name', 'data_type', 'is_nullable')
|
||||
.where('table_schema', 'public')
|
||||
.andWhere('table_name', table)
|
||||
|
||||
return rows.map((row) => {
|
||||
const col = {
|
||||
...row,
|
||||
isSpecial: ['created_at', 'updated_at', 'deleted_at'].includes(row.column_name),
|
||||
}
|
||||
return col
|
||||
})
|
||||
}
|
||||
|
||||
export async function getForeignKeysReferencing(table: string) {
|
||||
return await db
|
||||
.from('information_schema.columns')
|
||||
.select('table_name', 'column_name')
|
||||
.where('column_name', `${table}_id`)
|
||||
.andWhere('table_schema', 'public')
|
||||
}
|
||||
|
||||
export async function searchableColumns(table: string) {
|
||||
const columns = await getColumnsForTable(table)
|
||||
return {
|
||||
string: columns
|
||||
.filter((col) =>
|
||||
['character varying', 'text', 'varchar', 'char', 'uuid'].includes(col.data_type)
|
||||
)
|
||||
.map((col) => col.column_name),
|
||||
number: columns
|
||||
.filter((col) => ['integer', 'int4', 'int8', 'numeric', 'bigint'].includes(col.data_type))
|
||||
.map((col) => col.column_name),
|
||||
}
|
||||
}
|
||||
|
||||
export function addSearchFilter<T extends ModelQueryBuilderContract<any>>(
|
||||
query: T,
|
||||
columns: { string: string[]; number: string[] },
|
||||
search: string
|
||||
) {
|
||||
const keywords = search.trim().split(/\s+/)
|
||||
|
||||
query.where((builder) => {
|
||||
for (const keyword of keywords) {
|
||||
builder.orWhere((subBuilder) => {
|
||||
for (const column of columns.string) {
|
||||
subBuilder.orWhereILike(column, `%${keyword}%`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!Number.isNaN(Number(search))) {
|
||||
for (const column of columns.number) {
|
||||
builder.orWhere(column, Number(search))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
import Admins from '#base_modules/Admin/model/admin'
|
||||
import { readdirSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
|
||||
interface PermissionItem {
|
||||
slug: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export async function getAdminPermissionSlugs(admin: Admins): Promise<Set<string>> {
|
||||
await admin.load('roles')
|
||||
const roles = admin.roles.map((role) => role.id)
|
||||
const permissions = await db
|
||||
.from('role_permissions')
|
||||
.whereIn('role_id', roles)
|
||||
.select('permission_slug')
|
||||
|
||||
const slugs = new Set<string>()
|
||||
permissions.map((perm) => slugs.add(perm.permission_slug))
|
||||
return slugs
|
||||
}
|
||||
|
||||
export async function getAllPermissions(): Promise<PermissionItem[]> {
|
||||
const appRoot = process.cwd()
|
||||
const baseModulesPath = join(appRoot, 'app', 'base_modules')
|
||||
const modulesPath = join(appRoot, 'modules')
|
||||
|
||||
const result: PermissionItem[] = []
|
||||
|
||||
const loadPermissionsFromDir = async (dir: string) => {
|
||||
if (!existsSync(dir)) return
|
||||
|
||||
const modules = readdirSync(dir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name)
|
||||
|
||||
for (const moduleName of modules) {
|
||||
const configPath = join(dir, moduleName, 'config', 'permissions.ts')
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
const fileUrl = pathToFileURL(configPath)
|
||||
const mod = await import(fileUrl.href)
|
||||
const perms = mod.default || []
|
||||
if (Array.isArray(perms)) result.push(...perms)
|
||||
} catch (err) {
|
||||
console.error(`Failed to load permissions for module "${moduleName}":`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await loadPermissionsFromDir(baseModulesPath)
|
||||
await loadPermissionsFromDir(modulesPath)
|
||||
|
||||
return result
|
||||
}
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
const typeMap: Record<string, any> = {
|
||||
number: { type: 'integer', format: 'int64' },
|
||||
string: { type: 'string' },
|
||||
Date: { type: 'string', format: 'date-time' },
|
||||
boolean: { type: 'boolean' },
|
||||
}
|
||||
export async function modelToSchema(model: any) {
|
||||
const properties: Record<string, any> = {}
|
||||
for (const [key, meta] of model.$columnsDefinitions.entries()) {
|
||||
const tsType = meta.meta?.designType?.name || 'string'
|
||||
const mapped = typeMap[tsType] ?? { type: 'string' }
|
||||
properties[meta?.serializeAs || key] = {
|
||||
...mapped,
|
||||
description: key.replace(/([A-Z])/g, ' $1').trim(),
|
||||
}
|
||||
}
|
||||
return JSON.stringify(properties)
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import {{pascalCaseName}} from '#modules/{{capName}}/model/{{snakeCaseName}}'
|
||||
import { create{{pascalCaseName}}ApiValidator } from '#modules/{{capName}}/api/validators/{{snakeCaseName}}_create_api_validator'
|
||||
import { update{{pascalCaseName}}ApiValidator } from '#modules/{{capName}}/api/validators/{{snakeCaseName}}_update_api_validator'
|
||||
|
||||
export default class {{pascalCaseName}}ApiController {
|
||||
/**
|
||||
* GET /api/{{name}}
|
||||
*/
|
||||
/**
|
||||
* @index
|
||||
* @operationId get{{pascalCaseName}}
|
||||
* @description Returns array of records
|
||||
* @responseBody 200 - [{{{swaggerModel}}}]
|
||||
* @responseHeader 200 - X-pages - A description of the header - @example(test)
|
||||
*/
|
||||
async index({ response }: HttpContext) {
|
||||
const result = await {{pascalCaseName}}.all()
|
||||
return response.ok(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/{{name}}/:id
|
||||
*/
|
||||
|
||||
/**
|
||||
* @show
|
||||
* @operationId show{{pascalCaseName}}
|
||||
* @description Returns record
|
||||
* @responseBody 200 - {{{swaggerModel}}}
|
||||
* @responseBody 404 - { "message": "Not found" }
|
||||
* @responseHeader 200 - X-pages - A description of the header - @example(test)
|
||||
*/
|
||||
async show({ params, response }: HttpContext) {
|
||||
const record = await {{pascalCaseName}}.find(params.id)
|
||||
if (!record) return response.notFound({ message: 'Not found' })
|
||||
return response.ok(record)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/{{name}}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @store
|
||||
* @operationId store{{pascalCaseName}}
|
||||
* @description Creates and Returns record
|
||||
* @requestBody <create{{pascalCaseName}}ApiValidator>
|
||||
* @responseBody 200 - {{{swaggerModel}}}
|
||||
* @responseBody 404 - { "message": "Not found" }
|
||||
* @responseBody 422 - <Error422> - Returns an error if the request body fails validation.
|
||||
* @responseHeader 200 - X-pages - A description of the header - @example(test)
|
||||
*/
|
||||
async store({ request, response }: HttpContext) {
|
||||
const payload = await create{{pascalCaseName}}ApiValidator.validate(request.all())
|
||||
const record = await {{pascalCaseName}}.create(payload)
|
||||
return response.created(record)
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/{{name}}/:id
|
||||
*/
|
||||
|
||||
/**
|
||||
* @update
|
||||
* @operationId update{{pascalCaseName}}
|
||||
* @description Updates and Returns record
|
||||
* @requestBody <update{{pascalCaseName}}ApiValidator>
|
||||
* @responseBody 200 - {{{swaggerModel}}}
|
||||
* @responseBody 404 - { "message": "Not found" }
|
||||
* @responseBody 422 - <Error422> - Returns an error if the request body fails validation.
|
||||
* @responseHeader 200 - X-pages - A description of the header - @example(test)
|
||||
*/
|
||||
async update({ params, request, response }: HttpContext) {
|
||||
const record = await {{pascalCaseName}}.find(params.id)
|
||||
if (!record) return response.notFound({ message: 'Not found' })
|
||||
|
||||
const payload = await update{{pascalCaseName}}ApiValidator.validate(request.all())
|
||||
record.merge(payload)
|
||||
await record.save()
|
||||
return response.ok(record)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/{{name}}/:id
|
||||
*/
|
||||
|
||||
/**
|
||||
* @destroy
|
||||
* @operationId destroy{{pascalCaseName}}
|
||||
* @description Destroys record
|
||||
* @paramPath id - The ID of the source - @required
|
||||
* @responseBody 200 - { "message": "Deleted" }
|
||||
* @responseHeader 200 - X-pages - A description of the header - @example(test)
|
||||
*/
|
||||
async destroy({ params, response }: HttpContext) {
|
||||
const record = await {{pascalCaseName}}.find(params.id)
|
||||
if (!record) return response.notFound({ message: 'Not found' })
|
||||
|
||||
await record.delete()
|
||||
return response.ok({ message: 'Deleted' })
|
||||
}
|
||||
}
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
import router from '@adonisjs/core/services/router'
|
||||
const {{pascalCaseName}}ApiController = () => import('#modules/{{moduleName}}/api/controllers/{{snakeCaseName}}_api_controller')
|
||||
|
||||
router
|
||||
.group(() => {
|
||||
router.get('/', [{{pascalCaseName}}ApiController, 'index'])
|
||||
router.get('/:id', [{{pascalCaseName}}ApiController, 'show'])
|
||||
router.post('/', [{{pascalCaseName}}ApiController, 'store'])
|
||||
router.put('/:id', [{{pascalCaseName}}ApiController, 'update'])
|
||||
router.delete('/:id', [{{pascalCaseName}}ApiController, 'destroy'])
|
||||
})
|
||||
.prefix('/api/{{snakeCaseName}}')
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const {{functionName}} = vine.compile(
|
||||
vine.object({
|
||||
id: vine.number().optional(),
|
||||
{{#each fields}}
|
||||
{{name}}: vine.{{validatorType}}(){{#if isEmail}}.trim().email(){{/if}}{{#if isPassword}}.trim().minLength(8){{/if}}{{#if needsTrim}}.trim(){{/if}}{{#unless isRequired}}.optional(){{/unless}},
|
||||
{{/each}}
|
||||
})
|
||||
)
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
import { HttpContext } from '@adonisjs/core/http'
|
||||
import { create{{capName}}Validator } from '#modules/{{capName}}/validators/{{snakeName}}_create_validator'
|
||||
import { update{{capName}}Validator } from '#modules/{{capName}}/validators/{{snakeName}}_update_validator'
|
||||
import { addSearchFilter, searchableColumns } from '#services/database_inspector'
|
||||
import {{pascalCaseName}} from '#modules/{{capName}}/model/{{snakeName}}'
|
||||
import {{pascalCaseName}}Policy from '#modules/{{capName}}/policy/{{snakeName}}_policy'
|
||||
|
||||
export default class {{pascalCaseName}}Controller {
|
||||
public async index({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with({{pascalCaseName}}Policy).authorize('view{{pascalCaseName}}List')
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
search = null,
|
||||
sortBy = 'id',
|
||||
sortOrder = 'desc',
|
||||
} = request.all()
|
||||
|
||||
const query = {{pascalCaseName}}.query()
|
||||
|
||||
if (search) {
|
||||
const columns = {{pascalCaseName}}.searchable || (await searchableColumns('{{tableName}}'))
|
||||
addSearchFilter(query, columns, search)
|
||||
}
|
||||
|
||||
const result = await query.orderBy(sortBy, sortOrder).paginate(page, perPage)
|
||||
return response.ok(result.toJSON())
|
||||
}
|
||||
|
||||
public async store({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with({{pascalCaseName}}Policy).authorize('create{{pascalCaseName}}')
|
||||
|
||||
const data = await create{{capName}}Validator.validate(request.all())
|
||||
const record = await {{pascalCaseName}}.create(data)
|
||||
|
||||
if (record) {
|
||||
return response.created(record.toJSON())
|
||||
}
|
||||
return response.badRequest({ message: 'Cannot create.' })
|
||||
}
|
||||
|
||||
public async show({ request, response, bouncer }: HttpContext) {
|
||||
const { id } = request.params()
|
||||
if (!id) return response.notFound({ message: 'Not found.' })
|
||||
|
||||
const record = await {{pascalCaseName}}.find(id)
|
||||
if (!record) return response.notFound({ message: 'Not found.' })
|
||||
|
||||
await bouncer.with({{pascalCaseName}}Policy).authorize('view{{pascalCaseName}}')
|
||||
return response.ok(record.toJSON())
|
||||
}
|
||||
|
||||
public async update({ request, response, bouncer }: HttpContext) {
|
||||
const { id } = request.params()
|
||||
if (!id) return response.notFound({ message: 'Not found.' })
|
||||
|
||||
const data = await update{{capName}}Validator.validate(request.all())
|
||||
const record = await {{pascalCaseName}}.find(id)
|
||||
if (!record) return response.notFound({ message: 'Not found.' })
|
||||
|
||||
await bouncer.with({{pascalCaseName}}Policy).authorize('update{{pascalCaseName}}')
|
||||
record.merge(data)
|
||||
await record.save()
|
||||
|
||||
return response.ok(record.toJSON())
|
||||
}
|
||||
|
||||
public async destroy({ request, response, bouncer }: HttpContext) {
|
||||
const { id } = request.params()
|
||||
if (!id) return response.notFound({ message: 'Not found.' })
|
||||
|
||||
const record = await {{pascalCaseName}}.find(id)
|
||||
if (!record) return response.notFound({ message: 'Not found.' })
|
||||
|
||||
await bouncer.with({{pascalCaseName}}Policy).authorize('delete{{pascalCaseName}}')
|
||||
await {{pascalCaseName}}.query().where('id', id).delete()
|
||||
|
||||
return response.ok({ message: 'Deleted.' })
|
||||
}
|
||||
}
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
import { BaseModel, column{{#if imports}}, {{imports}}{{/if}} } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
|
||||
import { DateTime } from 'luxon'
|
||||
{{#if hasPassword}}
|
||||
import hash from '@adonisjs/core/services/hash'
|
||||
import { beforeSave } from '@adonisjs/lucid/orm'
|
||||
{{/if}}
|
||||
|
||||
export default class {{pascalCaseName}} extends BaseModel {
|
||||
static searchable = {
|
||||
string: [{{#each columns}}{{#if (and (eq validatorType "string") (not isPassword))}}'{{camelCase}}'{{#unless @last}}, {{/unless}}{{/if}}{{/each}}],
|
||||
number: [{{#each columns}}{{#if (eq validatorType "number")}}'{{camelCase}}'{{#unless @last}}, {{/unless}}{{/if}}{{/each}}],
|
||||
}
|
||||
|
||||
{{#each columns}}
|
||||
{{#if isPrimary}}
|
||||
@column({ isPrimary: true, columnName: '{{name}}', serializeAs: '{{name}}' })
|
||||
declare {{camelCase}}: {{type}}
|
||||
{{else if isDateTime}}
|
||||
@column.dateTime({ autoCreate: true{{#if (eq name "updated_at")}}, autoUpdate: true{{/if}}, columnName: '{{name}}', serializeAs: '{{name}}' })
|
||||
declare {{camelCase}}: DateTime | null
|
||||
{{else}}
|
||||
@column({ columnName: '{{name}}', serializeAs: '{{name}}' })
|
||||
declare {{camelCase}}: {{type}}
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
|
||||
{{#each relationships}}
|
||||
{{{this}}}
|
||||
{{/each}}
|
||||
|
||||
{{#if hasPassword}}
|
||||
@beforeSave()
|
||||
static async hashPassword(model: {{className}}) {
|
||||
if (model.$dirty.password) {
|
||||
model.password = await hash.make(model.password)
|
||||
}
|
||||
}
|
||||
{{/if}}
|
||||
}
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
import { BasePolicy } from '@adonisjs/bouncer'
|
||||
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
|
||||
import { getAdminPermissionSlugs } from '#services/permission_service'
|
||||
import Admins from '#base_modules/Admin/model/admin'
|
||||
|
||||
export default class {{pascalCaseName}}Policy extends BasePolicy {
|
||||
async view{{pascalCaseName}}List(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(await user)
|
||||
return perms.has('{{name}}s.view')
|
||||
}
|
||||
|
||||
async view{{pascalCaseName}}(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(await user)
|
||||
return perms.has('{{name}}s.view')
|
||||
}
|
||||
|
||||
async create{{pascalCaseName}}(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(await user)
|
||||
return perms.has('{{name}}s.create')
|
||||
}
|
||||
|
||||
async update{{pascalCaseName}}(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(await user)
|
||||
return perms.has('{{name}}s.update')
|
||||
}
|
||||
|
||||
async delete{{pascalCaseName}}(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(await user)
|
||||
return perms.has('{{name}}s.delete')
|
||||
}
|
||||
}
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
import router from '@adonisjs/core/services/router'
|
||||
import { middleware } from '#start/kernel'
|
||||
|
||||
const {{name}}Controller = () => import('#modules/{{capName}}/controller/{{snakeCaseName}}_controller')
|
||||
|
||||
router.resource('/{{routeName}}', {{name}}Controller).apiOnly().use('*', [middleware.auth()])
|
||||
|
||||
// If you prefer only mutating actions protected, use this instead:
|
||||
// router.resource('/{{routeName}}', {{name}}Controller).apiOnly().use(['store', 'update', 'destroy'], [middleware.auth()])
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test } from '@japa/runner'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
|
||||
test.group('API /api/{{snakeCaseName}}', (group) => {
|
||||
group.each.setup(async () => {
|
||||
await db.beginGlobalTransaction()
|
||||
return () => db.rollbackGlobalTransaction()
|
||||
})
|
||||
|
||||
test('GET /api/{{snakeCaseName}} -> list', async ({ client }) => {
|
||||
const res = await client.get('/api/{{snakeCaseName}}')
|
||||
res.assertStatus(200)
|
||||
})
|
||||
|
||||
test('POST /api/{{snakeCaseName}} -> create', async ({ client }) => {
|
||||
const payload = {
|
||||
{{#each createFields}}
|
||||
{{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}}
|
||||
{{/each}}
|
||||
}
|
||||
const res = await client.post('/api/{{snakeCaseName}}').json(payload)
|
||||
res.assertStatus(201)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { test } from '@japa/runner'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import { loginAsAdmin } from '../../utils/auth.js'
|
||||
|
||||
test.group('{{pascalCaseName}}Controller', (group) => {
|
||||
group.each.setup(async () => {
|
||||
await db.beginGlobalTransaction()
|
||||
return () => db.rollbackGlobalTransaction()
|
||||
})
|
||||
|
||||
test('index: lists paginated {{snakeCaseName}}', async ({ client, assert }) => {
|
||||
const token = await loginAsAdmin(client)
|
||||
const res = await client.get('/{{snakeCaseName}}').header('Authorization', `Bearer ${token}`)
|
||||
res.assertStatus(200)
|
||||
assert.exists(res.body().meta, 'pagination meta exists')
|
||||
})
|
||||
|
||||
test('store: creates a new {{snakeCaseName}}', async ({ client, assert }) => {
|
||||
const token = await loginAsAdmin(client)
|
||||
|
||||
const payload = {
|
||||
{{#each createFields}}
|
||||
{{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}}
|
||||
{{/each}}
|
||||
}
|
||||
|
||||
const res = await client
|
||||
.post('/{{snakeCaseName}}')
|
||||
.header('Authorization', `Bearer ${token}`)
|
||||
.json(payload)
|
||||
|
||||
res.assertStatus(201)
|
||||
const body = res.body()
|
||||
{{#each createFields}}
|
||||
assert.property(body, '{{column_name}}')
|
||||
{{/each}}
|
||||
})
|
||||
|
||||
test('show: returns one {{snakeCaseName}} by id', async ({ client }) => {
|
||||
const token = await loginAsAdmin(client)
|
||||
|
||||
const created = await db
|
||||
.table('{{tableName}}')
|
||||
.insert(
|
||||
{{{insertJson}}}
|
||||
)
|
||||
.returning('id')
|
||||
|
||||
const id = Array.isArray(created) ? (created[0]?.id ?? created[0]) : created
|
||||
|
||||
const res = await client
|
||||
.get(`/{{snakeCaseName}}/${id}`)
|
||||
.header('Authorization', `Bearer ${token}`)
|
||||
|
||||
res.assertStatus(200)
|
||||
})
|
||||
|
||||
test('update: updates {{snakeCaseName}}', async ({ client, assert }) => {
|
||||
const token = await loginAsAdmin(client)
|
||||
|
||||
const created = await db
|
||||
.table('{{tableName}}')
|
||||
.insert(
|
||||
{{{insertJson}}}
|
||||
)
|
||||
.returning('id')
|
||||
|
||||
const id = Array.isArray(created) ? (created[0]?.id ?? created[0]) : created
|
||||
|
||||
const patch = {
|
||||
{{#each updateFields}}
|
||||
{{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}}
|
||||
{{/each}}
|
||||
}
|
||||
|
||||
const res = await client
|
||||
.put(`/{{snakeCaseName}}/${id}`)
|
||||
.header('Authorization', `Bearer ${token}`)
|
||||
.json(patch)
|
||||
|
||||
res.assertStatus(200)
|
||||
const body = res.body()
|
||||
{{#each updateFields}}
|
||||
assert.property(body, '{{column_name}}')
|
||||
{{/each}}
|
||||
})
|
||||
|
||||
test('destroy: deletes {{snakeCaseName}}', async ({ client }) => {
|
||||
const token = await loginAsAdmin(client)
|
||||
|
||||
const created = await db
|
||||
.table('{{tableName}}')
|
||||
.insert(
|
||||
{{{insertJson}}}
|
||||
)
|
||||
.returning('id')
|
||||
|
||||
const id = Array.isArray(created) ? (created[0]?.id ?? created[0]) : created
|
||||
|
||||
const res = await client
|
||||
.delete(`/{{snakeCaseName}}/${id}`)
|
||||
.header('Authorization', `Bearer ${token}`)
|
||||
|
||||
res.assertStatus(200)
|
||||
})
|
||||
})
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const {{functionName}} = vine.compile(
|
||||
vine.object({
|
||||
id: vine.number().optional(),
|
||||
{{#each fields}}
|
||||
{{name}}: vine.{{validatorType}}(){{#if isEmail}}.trim().email(){{/if}}{{#if isPassword}}.trim().minLength(8){{/if}}{{#if needsTrim}}.trim(){{/if}}{{#unless isRequired}}.optional(){{/unless}},
|
||||
{{/each}}
|
||||
})
|
||||
)
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
export const MODULE_NAME = '{{name}}'
|
||||
export const MODULE_NAME_LABEL = MODULE_NAME.replace(/^./, (char) => char.toUpperCase()); // prettier-ignore
|
||||
export const MODULE_API_ENDPOINT = '/' + MODULE_NAME
|
||||
export const MODULE_ROOT_PATH = '/' + MODULE_NAME
|
||||
export const MODULE_PERMISSION_ROOT = MODULE_NAME + 's'
|
||||
export const MODULE_PERMISSION_CREATE = MODULE_PERMISSION_ROOT + '.create'
|
||||
export const MODULE_PERMISSION_UPDATE = MODULE_PERMISSION_ROOT + '.update'
|
||||
export const MODULE_PERMISSION_DELETE = MODULE_PERMISSION_ROOT + '.delete'
|
||||
export const MODULE_PERMISSION_VIEW = MODULE_PERMISSION_ROOT + '.view'
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
{{detaTable}}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { IsoDateString } from '@crux-kit/types';
|
||||
|
||||
export interface Entity {
|
||||
{{#each fields}}
|
||||
{{name}}: {{type}}
|
||||
{{/each}}
|
||||
}
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
{{form}}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { VineMultipartFile } from '@adonisjs/core/vine'
|
||||
import { OptionalModifier } from '@vinejs/vine/schema/base/literal'
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
/**
|
||||
* Creates a reusable upload file schema that requires either:
|
||||
* - `ref` (reference to an existing file), or
|
||||
* - `file` (new uploaded file).
|
||||
*
|
||||
* At least one field must be present. When `ref` is missing, `file`
|
||||
* becomes required. When `file` is missing, `ref` becomes required.
|
||||
*
|
||||
* @param fileScheme Custom optional file validation schema.
|
||||
* @returns Vine object schema with mutually dependent `ref` and `file` fields.
|
||||
*/
|
||||
export default function createFileInputSchema(
|
||||
fileScheme: OptionalModifier<VineMultipartFile> = vine.file({ size: '5mb' }).optional()
|
||||
) {
|
||||
return vine.object({
|
||||
ref: vine.string().optional().requiredIfMissing('file'),
|
||||
file: fileScheme.requiredIfMissing('ref'),
|
||||
})
|
||||
}
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Ace entry point
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The "console.ts" file is the entrypoint for booting the AdonisJS
|
||||
| command-line framework and executing commands.
|
||||
|
|
||||
| Commands do not boot the application, unless the currently running command
|
||||
| has "options.startApp" flag set to true.
|
||||
|
|
||||
*/
|
||||
|
||||
import 'reflect-metadata'
|
||||
import { Ignitor, prettyPrintError } from '@adonisjs/core'
|
||||
|
||||
/**
|
||||
* URL to the application root. AdonisJS need it to resolve
|
||||
* paths to file and directories for scaffolding commands
|
||||
*/
|
||||
const APP_ROOT = new URL('../', import.meta.url)
|
||||
|
||||
/**
|
||||
* The importer is used to import files in context of the
|
||||
* application.
|
||||
*/
|
||||
const IMPORTER = (filePath: string) => {
|
||||
if (filePath.startsWith('./') || filePath.startsWith('../')) {
|
||||
return import(new URL(filePath, APP_ROOT).href)
|
||||
}
|
||||
return import(filePath)
|
||||
}
|
||||
|
||||
new Ignitor(APP_ROOT, { importer: IMPORTER })
|
||||
.tap((app) => {
|
||||
app.booting(async () => {
|
||||
await import('#start/env')
|
||||
})
|
||||
app.listen('SIGTERM', () => app.terminate())
|
||||
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
|
||||
})
|
||||
.ace()
|
||||
.handle(process.argv.splice(2))
|
||||
.catch((error) => {
|
||||
process.exitCode = 1
|
||||
prettyPrintError(error)
|
||||
})
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP server entrypoint
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The "server.ts" file is the entrypoint for starting the AdonisJS HTTP
|
||||
| server. Either you can run this file directly or use the "serve"
|
||||
| command to run this file and monitor file changes
|
||||
|
|
||||
*/
|
||||
|
||||
import 'reflect-metadata'
|
||||
import { Ignitor, prettyPrintError } from '@adonisjs/core'
|
||||
|
||||
/**
|
||||
* URL to the application root. AdonisJS need it to resolve
|
||||
* paths to file and directories for scaffolding commands
|
||||
*/
|
||||
const APP_ROOT = new URL('../', import.meta.url)
|
||||
|
||||
/**
|
||||
* The importer is used to import files in context of the
|
||||
* application.
|
||||
*/
|
||||
const IMPORTER = (filePath: string) => {
|
||||
if (filePath.startsWith('./') || filePath.startsWith('../')) {
|
||||
return import(new URL(filePath, APP_ROOT).href)
|
||||
}
|
||||
return import(filePath)
|
||||
}
|
||||
|
||||
new Ignitor(APP_ROOT, { importer: IMPORTER })
|
||||
.tap((app) => {
|
||||
app.booting(async () => {
|
||||
await import('#start/env')
|
||||
})
|
||||
app.listen('SIGTERM', () => app.terminate())
|
||||
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
|
||||
})
|
||||
.httpServer()
|
||||
.start()
|
||||
.catch((error) => {
|
||||
process.exitCode = 1
|
||||
prettyPrintError(error)
|
||||
})
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Test runner entrypoint
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The "test.ts" file is the entrypoint for running tests using Japa.
|
||||
|
|
||||
| Either you can run this file directly or use the "test"
|
||||
| command to run this file and monitor file changes.
|
||||
|
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'test'
|
||||
|
||||
import 'reflect-metadata'
|
||||
import { Ignitor, prettyPrintError } from '@adonisjs/core'
|
||||
import { configure, processCLIArgs, run } from '@japa/runner'
|
||||
|
||||
/**
|
||||
* URL to the application root. AdonisJS need it to resolve
|
||||
* paths to file and directories for scaffolding commands
|
||||
*/
|
||||
const APP_ROOT = new URL('../', import.meta.url)
|
||||
|
||||
/**
|
||||
* The importer is used to import files in context of the
|
||||
* application.
|
||||
*/
|
||||
const IMPORTER = (filePath: string) => {
|
||||
if (filePath.startsWith('./') || filePath.startsWith('../')) {
|
||||
return import(new URL(filePath, APP_ROOT).href)
|
||||
}
|
||||
return import(filePath)
|
||||
}
|
||||
|
||||
new Ignitor(APP_ROOT, { importer: IMPORTER })
|
||||
.tap((app) => {
|
||||
app.booting(async () => {
|
||||
await import('#start/env')
|
||||
})
|
||||
app.listen('SIGTERM', () => app.terminate())
|
||||
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
|
||||
})
|
||||
.testRunner()
|
||||
.configure(async (app) => {
|
||||
const { runnerHooks, ...config } = await import('../tests/bootstrap.js')
|
||||
|
||||
processCLIArgs(process.argv.splice(2))
|
||||
configure({
|
||||
...app.rcFile.tests,
|
||||
...config,
|
||||
...{
|
||||
setup: runnerHooks.setup,
|
||||
teardown: runnerHooks.teardown.concat([() => app.terminate()]),
|
||||
},
|
||||
})
|
||||
})
|
||||
.run(() => run())
|
||||
.catch((error) => {
|
||||
process.exitCode = 1
|
||||
prettyPrintError(error)
|
||||
})
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
import env from '#start/env'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { Secret } from '@adonisjs/core/helpers'
|
||||
import { defineConfig } from '@adonisjs/core/http'
|
||||
|
||||
/**
|
||||
* The app key is used for encrypting cookies, generating signed URLs,
|
||||
* and by the "encryption" module.
|
||||
*
|
||||
* The encryption module will fail to decrypt data if the key is lost or
|
||||
* changed. Therefore it is recommended to keep the app key secure.
|
||||
*/
|
||||
export const appKey = new Secret(env.get('APP_KEY'))
|
||||
|
||||
/**
|
||||
* The configuration settings used by the HTTP server
|
||||
*/
|
||||
export const http = defineConfig({
|
||||
generateRequestId: true,
|
||||
allowMethodSpoofing: false,
|
||||
|
||||
/**
|
||||
* Enabling async local storage will let you access HTTP context
|
||||
* from anywhere inside your application.
|
||||
*/
|
||||
useAsyncLocalStorage: false,
|
||||
|
||||
/**
|
||||
* Manage cookies configuration. The settings for the session id cookie are
|
||||
* defined inside the "config/session.ts" file.
|
||||
*/
|
||||
cookie: {
|
||||
domain: '',
|
||||
path: '/',
|
||||
maxAge: '2h',
|
||||
httpOnly: true,
|
||||
secure: app.inProduction,
|
||||
sameSite: 'lax',
|
||||
},
|
||||
})
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from '@adonisjs/auth'
|
||||
import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens'
|
||||
import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types'
|
||||
|
||||
const authConfig = defineConfig({
|
||||
default: 'api',
|
||||
guards: {
|
||||
api: tokensGuard({
|
||||
provider: tokensUserProvider({
|
||||
tokens: 'accessTokens',
|
||||
model: () => import('../app/base_modules/Admin/model/admin.js'),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export default authConfig
|
||||
|
||||
/**
|
||||
* Inferring types from the configured auth
|
||||
* guards.
|
||||
*/
|
||||
declare module '@adonisjs/auth/types' {
|
||||
export interface Authenticators extends InferAuthenticators<typeof authConfig> {}
|
||||
}
|
||||
declare module '@adonisjs/core/types' {
|
||||
interface EventsList extends InferAuthEvents<Authenticators> {}
|
||||
}
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
import { defineConfig } from '@adonisjs/core/bodyparser'
|
||||
|
||||
const bodyParserConfig = defineConfig({
|
||||
/**
|
||||
* The bodyparser middleware will parse the request body
|
||||
* for the following HTTP methods.
|
||||
*/
|
||||
allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
|
||||
/**
|
||||
* Config for the "application/x-www-form-urlencoded"
|
||||
* content-type parser
|
||||
*/
|
||||
form: {
|
||||
convertEmptyStringsToNull: true,
|
||||
types: ['application/x-www-form-urlencoded'],
|
||||
},
|
||||
|
||||
/**
|
||||
* Config for the JSON parser
|
||||
*/
|
||||
json: {
|
||||
convertEmptyStringsToNull: true,
|
||||
types: [
|
||||
'application/json',
|
||||
'application/json-patch+json',
|
||||
'application/vnd.api+json',
|
||||
'application/csp-report',
|
||||
],
|
||||
},
|
||||
|
||||
/**
|
||||
* Config for the "multipart/form-data" content-type parser.
|
||||
* File uploads are handled by the multipart parser.
|
||||
*/
|
||||
multipart: {
|
||||
/**
|
||||
* Enabling auto process allows bodyparser middleware to
|
||||
* move all uploaded files inside the tmp folder of your
|
||||
* operating system
|
||||
*/
|
||||
autoProcess: true,
|
||||
convertEmptyStringsToNull: true,
|
||||
processManually: [],
|
||||
|
||||
/**
|
||||
* Maximum limit of data to parse including all files
|
||||
* and fields
|
||||
*/
|
||||
limit: '20mb',
|
||||
types: ['multipart/form-data'],
|
||||
},
|
||||
})
|
||||
|
||||
export default bodyParserConfig
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from '@adonisjs/cors'
|
||||
|
||||
/**
|
||||
* Configuration options to tweak the CORS policy. The following
|
||||
* options are documented on the official documentation website.
|
||||
*
|
||||
* https://docs.adonisjs.com/guides/security/cors
|
||||
*/
|
||||
const corsConfig = defineConfig({
|
||||
enabled: true,
|
||||
origin: true,
|
||||
methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'],
|
||||
headers: true,
|
||||
exposeHeaders: [],
|
||||
credentials: true,
|
||||
maxAge: 90,
|
||||
})
|
||||
|
||||
export default corsConfig
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
import env from '#start/env'
|
||||
import { defineConfig } from '@adonisjs/lucid'
|
||||
|
||||
const dbConfig = defineConfig({
|
||||
connection: 'postgres',
|
||||
connections: {
|
||||
postgres: {
|
||||
client: 'pg',
|
||||
connection: {
|
||||
host: env.get('DB_HOST'),
|
||||
port: env.get('DB_PORT'),
|
||||
user: env.get('DB_USER'),
|
||||
password: env.get('DB_PASSWORD'),
|
||||
database: env.get('DB_DATABASE'),
|
||||
},
|
||||
migrations: {
|
||||
naturalSort: true,
|
||||
paths: ['database/migrations'],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default dbConfig
|
||||
@@ -0,0 +1,34 @@
|
||||
import env from '#start/env'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { defineConfig, services } from '@adonisjs/drive'
|
||||
|
||||
const driveConfig = defineConfig({
|
||||
default: env.get('DRIVE_DISK'),
|
||||
services: {
|
||||
fs: services.fs({
|
||||
location: app.makePath('public/uploads'),
|
||||
serveFiles: true,
|
||||
routeBasePath: '/uploads',
|
||||
visibility: 'public',
|
||||
}),
|
||||
s3: services.s3({
|
||||
credentials: {
|
||||
accessKeyId: env.get('AWS_ACCESS_KEY_ID', 'minioadmin'),
|
||||
secretAccessKey: env.get('AWS_SECRET_ACCESS_KEY', 'minioadmin'),
|
||||
},
|
||||
region: env.get('AWS_REGION', 'us-east-1'),
|
||||
bucket: env.get('S3_BUCKET', 'crux-drive'),
|
||||
endpoint: env.get('S3_ENDPOINT'),
|
||||
forcePathStyle: true,
|
||||
visibility: 'public',
|
||||
supportsACL: false,
|
||||
cdnUrl: env.get('S3_CDN_URL'),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export default driveConfig
|
||||
|
||||
declare module '@adonisjs/drive/types' {
|
||||
export interface DriveDisks extends InferDriveDisks<typeof driveConfig> {}
|
||||
}
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
import { defineConfig, drivers } from '@adonisjs/core/hash'
|
||||
|
||||
const hashConfig = defineConfig({
|
||||
default: 'scrypt',
|
||||
|
||||
list: {
|
||||
scrypt: drivers.scrypt({
|
||||
cost: 16384,
|
||||
blockSize: 8,
|
||||
parallelization: 1,
|
||||
maxMemory: 33554432,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export default hashConfig
|
||||
|
||||
/**
|
||||
* Inferring types for the list of hashers you have configured
|
||||
* in your application.
|
||||
*/
|
||||
declare module '@adonisjs/core/types' {
|
||||
export interface HashersList extends InferHashers<typeof hashConfig> {}
|
||||
}
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
import env from '#start/env'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { defineConfig, targets } from '@adonisjs/core/logger'
|
||||
|
||||
const loggerConfig = defineConfig({
|
||||
default: 'app',
|
||||
/**
|
||||
* The loggers object can be used to define multiple loggers.
|
||||
* By default, we configure only one logger (named "app").
|
||||
*/
|
||||
loggers: {
|
||||
app: {
|
||||
enabled: true,
|
||||
name: env.get('APP_NAME'),
|
||||
level: env.get('LOG_LEVEL'),
|
||||
transport: {
|
||||
targets: targets()
|
||||
.pushIf(!app.inProduction, targets.pretty())
|
||||
.pushIf(app.inProduction, targets.file({ destination: 1 }))
|
||||
.toArray(),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default loggerConfig
|
||||
|
||||
/**
|
||||
* Inferring types for the list of loggers you have configured
|
||||
* in your application.
|
||||
*/
|
||||
declare module '@adonisjs/core/types' {
|
||||
export interface LoggersList extends InferLoggers<typeof loggerConfig> {}
|
||||
}
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
// for AdonisJS v6
|
||||
import path from 'node:path'
|
||||
import url from 'node:url'
|
||||
// ---
|
||||
|
||||
export default {
|
||||
// path: __dirname + "/../", for AdonisJS v5
|
||||
path: path.dirname(url.fileURLToPath(import.meta.url)) + '/../', // for AdonisJS v6
|
||||
title: 'Foo', // use info instead
|
||||
version: '1.0.0', // use info instead
|
||||
description: '', // use info instead
|
||||
tagIndex: 2,
|
||||
productionEnv: 'production', // optional
|
||||
info: {
|
||||
title: 'Crux',
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
},
|
||||
snakeCase: true,
|
||||
|
||||
debug: true, // set to true, to get some useful debug output
|
||||
ignore: [
|
||||
'/swagger',
|
||||
'/docs',
|
||||
'/auth*',
|
||||
'/admin*',
|
||||
'/permission*',
|
||||
'/role*',
|
||||
'/makeModule*',
|
||||
'/testing*',
|
||||
],
|
||||
preferredPutPatch: 'PUT', // if PUT/PATCH are provided for the same route, prefer PUT
|
||||
common: {
|
||||
parameters: {}, // OpenAPI conform parameters that are commonly used
|
||||
headers: {}, // OpenAPI conform headers that are commonly used
|
||||
},
|
||||
securitySchemes: {}, // optional
|
||||
authMiddlewares: ['auth', 'auth:api'], // optional
|
||||
defaultSecurityScheme: 'BearerAuth', // optional
|
||||
persistAuthorization: true, // persist authorization between reloads on the swagger page
|
||||
showFullPath: false, // the path displayed after endpoint summary,
|
||||
// modulesPath: {
|
||||
// models: ['models', 'api/models'],
|
||||
// interfaces: ['interfaces', 'api/interfaces'],
|
||||
// validators: ['validators', 'api/validators'],
|
||||
// types: ['types', 'api/types'],
|
||||
// },
|
||||
modulesPath: {
|
||||
models: ['api/models'],
|
||||
interfaces: ['api/interfaces'],
|
||||
validators: ['api/validators'],
|
||||
types: ['api/types'],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'admins'
|
||||
|
||||
async up() {
|
||||
await this.schema.raw('DROP TYPE IF EXISTS admin_status_enum')
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id').notNullable()
|
||||
table.string('email').notNullable().unique()
|
||||
table.string('password').notNullable()
|
||||
table.string('first_name').nullable()
|
||||
table.string('last_name').nullable()
|
||||
table
|
||||
.enu('status', ['active', 'inactive'], {
|
||||
useNative: true,
|
||||
enumName: 'admin_status_enum',
|
||||
})
|
||||
.notNullable()
|
||||
.defaultTo('active')
|
||||
table.timestamp('created_at').notNullable()
|
||||
table.timestamp('updated_at').nullable()
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'auth_access_tokens'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table
|
||||
.integer('tokenable_id')
|
||||
.notNullable()
|
||||
.unsigned()
|
||||
.references('id')
|
||||
.inTable('admins')
|
||||
.onDelete('CASCADE')
|
||||
|
||||
table.string('type').notNullable()
|
||||
table.string('name').nullable()
|
||||
table.string('hash').notNullable()
|
||||
table.text('abilities').notNullable()
|
||||
table.timestamp('created_at')
|
||||
table.timestamp('updated_at')
|
||||
table.timestamp('last_used_at').nullable()
|
||||
table.timestamp('expires_at').nullable()
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'roles'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table.string('name').notNullable().unique()
|
||||
table.timestamp('created_at')
|
||||
table.timestamp('updated_at')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'permissions'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table.string('name').notNullable().unique()
|
||||
table.string('slug').notNullable().unique()
|
||||
table.timestamp('created_at')
|
||||
table.timestamp('updated_at')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'admin_roles'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE')
|
||||
table.integer('admin_id').unsigned().references('id').inTable('admins').onDelete('CASCADE')
|
||||
table.unique(['role_id', 'admin_id'])
|
||||
table.timestamp('created_at')
|
||||
table.timestamp('updated_at')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'role_permissions'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE')
|
||||
table.string('permission_slug').unsigned()
|
||||
table.unique(['role_id', 'permission_slug'])
|
||||
table.timestamp('created_at')
|
||||
table.timestamp('updated_at')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'testings'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table.string('name').notNullable().unique()
|
||||
table.timestamp('created_at')
|
||||
table.timestamp('updated_at')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user