Add initial project files

This commit is contained in:
Igor Martyniuk
2026-09-01 13:03:41 +03:00
parent 22e111bb44
commit 74cb5b6176
1841 changed files with 242819 additions and 30 deletions
+148
View File
@@ -0,0 +1,148 @@
import '@adonisjs/core/types/http'
type ParamValue = string | number | bigint | boolean
export type ScannedRoutes = {
ALL: {
'drive.fs.serve': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'admin.update_profile': { paramsTuple?: []; params?: {} }
'admin.index': { paramsTuple?: []; params?: {} }
'admin.store': { paramsTuple?: []; params?: {} }
'admin.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'admin.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'admin.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'auth.login': { paramsTuple?: []; params?: {} }
'auth.logout': { paramsTuple?: []; params?: {} }
'auth.user': { paramsTuple?: []; params?: {} }
'root': { paramsTuple?: []; params?: {} }
'file_manager.rename': { paramsTuple?: []; params?: {} }
'file_manager.create_folder': { paramsTuple?: []; params?: {} }
'file_manager.raw': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'file_manager.show': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'file_manager.upload': { paramsTuple?: []; params?: {} }
'file_manager.duplicate': { paramsTuple?: []; params?: {} }
'file_manager.search': { paramsTuple?: []; params?: {} }
'file_manager.destroy': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'nested': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'permission.index': { paramsTuple?: []; params?: {} }
'permission.store': { paramsTuple?: []; params?: {} }
'permission.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'permission.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'permission.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'role.index': { paramsTuple?: []; params?: {} }
'role.store': { paramsTuple?: []; params?: {} }
'role.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'role.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'role.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'public_web_manager.blog_list': { paramsTuple?: []; params?: {} }
'public_web_manager.sitemap': { paramsTuple?: []; params?: {} }
'public_web_manager.resolve': { paramsTuple?: []; params?: {} }
'public_web_manager.preview': { paramsTuple?: []; params?: {} }
'public_web_manager.settings': { paramsTuple?: []; params?: {} }
'web_manager.index': { paramsTuple?: []; params?: {} }
'web_manager.group_keys': { paramsTuple?: []; params?: {} }
'web_manager.store': { paramsTuple?: []; params?: {} }
'web_manager.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'web_manager.save_draft': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'web_manager.publish': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'web_manager.unpublish': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'web_manager.create_preview_token': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'web_manager.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'web_manager.get_settings': { paramsTuple?: []; params?: {} }
'web_manager.save_settings': { paramsTuple?: []; params?: {} }
'module.tables': { paramsTuple?: []; params?: {} }
'module.columns': { paramsTuple: [ParamValue]; params: {'table': ParamValue} }
'module.generate': { paramsTuple?: []; params?: {} }
}
GET: {
'drive.fs.serve': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'admin.index': { paramsTuple?: []; params?: {} }
'admin.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'auth.user': { paramsTuple?: []; params?: {} }
'root': { paramsTuple?: []; params?: {} }
'file_manager.raw': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'file_manager.show': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'file_manager.search': { paramsTuple?: []; params?: {} }
'nested': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'permission.index': { paramsTuple?: []; params?: {} }
'permission.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'role.index': { paramsTuple?: []; params?: {} }
'role.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'public_web_manager.blog_list': { paramsTuple?: []; params?: {} }
'public_web_manager.sitemap': { paramsTuple?: []; params?: {} }
'public_web_manager.resolve': { paramsTuple?: []; params?: {} }
'public_web_manager.preview': { paramsTuple?: []; params?: {} }
'public_web_manager.settings': { paramsTuple?: []; params?: {} }
'web_manager.index': { paramsTuple?: []; params?: {} }
'web_manager.group_keys': { paramsTuple?: []; params?: {} }
'web_manager.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'web_manager.get_settings': { paramsTuple?: []; params?: {} }
'module.tables': { paramsTuple?: []; params?: {} }
'module.columns': { paramsTuple: [ParamValue]; params: {'table': ParamValue} }
}
HEAD: {
'drive.fs.serve': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'admin.index': { paramsTuple?: []; params?: {} }
'admin.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'auth.user': { paramsTuple?: []; params?: {} }
'root': { paramsTuple?: []; params?: {} }
'file_manager.raw': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'file_manager.show': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'file_manager.search': { paramsTuple?: []; params?: {} }
'nested': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'permission.index': { paramsTuple?: []; params?: {} }
'permission.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'role.index': { paramsTuple?: []; params?: {} }
'role.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'public_web_manager.blog_list': { paramsTuple?: []; params?: {} }
'public_web_manager.sitemap': { paramsTuple?: []; params?: {} }
'public_web_manager.resolve': { paramsTuple?: []; params?: {} }
'public_web_manager.preview': { paramsTuple?: []; params?: {} }
'public_web_manager.settings': { paramsTuple?: []; params?: {} }
'web_manager.index': { paramsTuple?: []; params?: {} }
'web_manager.group_keys': { paramsTuple?: []; params?: {} }
'web_manager.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'web_manager.get_settings': { paramsTuple?: []; params?: {} }
'module.tables': { paramsTuple?: []; params?: {} }
'module.columns': { paramsTuple: [ParamValue]; params: {'table': ParamValue} }
}
PUT: {
'admin.update_profile': { paramsTuple?: []; params?: {} }
'admin.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'file_manager.rename': { paramsTuple?: []; params?: {} }
'permission.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'role.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'web_manager.save_draft': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'web_manager.save_settings': { paramsTuple?: []; params?: {} }
}
POST: {
'admin.store': { paramsTuple?: []; params?: {} }
'auth.login': { paramsTuple?: []; params?: {} }
'auth.logout': { paramsTuple?: []; params?: {} }
'file_manager.create_folder': { paramsTuple?: []; params?: {} }
'file_manager.upload': { paramsTuple?: []; params?: {} }
'file_manager.duplicate': { paramsTuple?: []; params?: {} }
'permission.store': { paramsTuple?: []; params?: {} }
'role.store': { paramsTuple?: []; params?: {} }
'web_manager.store': { paramsTuple?: []; params?: {} }
'web_manager.publish': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'web_manager.unpublish': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'web_manager.create_preview_token': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'module.generate': { paramsTuple?: []; params?: {} }
}
PATCH: {
'admin.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'permission.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'role.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
}
DELETE: {
'admin.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'file_manager.destroy': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'permission.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'role.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'web_manager.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
}
}
declare module '@adonisjs/core/types/http' {
export interface RoutesList extends ScannedRoutes {}
}
+1
View File
@@ -0,0 +1 @@
client/
+22
View File
@@ -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
+20
View File
@@ -0,0 +1,20 @@
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
WEB_APP_URL=http://localhost:3000
WEB_REVALIDATE_SECRET=
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/
+13
View File
@@ -0,0 +1,13 @@
# Test overrides, merged on top of .env when NODE_ENV=test (bin/test.ts forces it).
# Tests run against a dedicated database so they never touch the seeded dev data.
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_DATABASE=adonis_test
LOG_LEVEL=error
# Empty secret disables outbound cache purges: WebRevalidationService reports
# status "disabled" and still returns the computed tags, which specs assert on.
WEB_REVALIDATE_SECRET=
# Keep file storage local; specs do not upload files.
DRIVE_DISK=fs
+8
View File
@@ -0,0 +1,8 @@
build
dist
public/**
storage/**
app/templates/**
database/schema.ts
.adonisjs
+19
View File
@@ -0,0 +1,19 @@
# Adonis v7 backend (development mode)
FROM node:24-alpine3.22
WORKDIR /app
# Copy all workspaces before installation so npm can resolve their dependencies.
COPY package.json package-lock.json ./
COPY apps ./apps
COPY packages ./packages
# Install workspace dependencies (monorepo-aware).
RUN npm ci --force
WORKDIR /app/apps/backend
# Expose dev port
EXPOSE 3333
CMD ["npm", "run", "dev"]
+27
View File
@@ -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')
+98
View File
@@ -0,0 +1,98 @@
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/vine'),
() => 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: 'unit',
files: ['tests/unit/**/*.spec.@(ts|js)'],
},
{
name: 'functional',
files: [
'tests/functional/**/*.spec.@(ts|js)',
'tests/modules/**/*.spec.@(ts|js)',
'modules/**/tests/**/*.spec.@(ts|js)',
],
},
],
forceExit: false,
},
})
+23
View File
@@ -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
View File
@@ -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,96 @@
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',
status = null,
} = request.all()
const query = Admins.query()
if (status) {
const statuses = Array.isArray(status) ? status : [status]
query.whereIn('status', statuses)
}
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
View File
@@ -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
View File
@@ -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')
}
}
+7
View File
@@ -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()),
})
)
@@ -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) })
}
}
+8
View File
@@ -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',
},
]
@@ -0,0 +1,150 @@
import type { 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 type { 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,15 @@
import vine from '@vinejs/vine'
import type { HttpContext } 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: HttpContext['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
View File
@@ -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']).as('root')
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']).as('nested')
})
.prefix('/file-manager')
.use([middleware.auth()])
@@ -0,0 +1,132 @@
import type { 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 type { MultipartFile } from '@adonisjs/core/bodyparser'
import type { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
import {
default as EntryListService,
type EntryListParams,
type EntryListResult,
} from '#base_modules/FileManager/services/entry_list_service'
import createStorageService from '#base_modules/FileManager/services/storage_service/create_storage_service'
import type {
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_exceptions'
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 type { 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 { randomUUID } from 'node:crypto'
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 = `${randomUUID()}${ext}`
break
}
}
return newName
}
}
@@ -0,0 +1,21 @@
import env from '#start/env'
import type { 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)
}
}
@@ -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,274 @@
import drive from '@adonisjs/drive/services/main'
import {
DirExistsException,
DirNotFoundException,
FileNotFoundException,
} from '#exceptions/file_manager_exceptions'
import PathService from '#base_modules/FileManager/services/path_service'
import type { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
import type { StorageService } from '#base_modules/FileManager/services/storage_service/storage_service_interface'
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 directoryPrefix(path: string): string {
return path ? `${path.replace(/\/$/, '')}/` : path
}
private async listDirectoryFiles(path: string): Promise<{ key: string; relative: string }[]> {
const prefix = this.directoryPrefix(path)
const files: { key: string; relative: string }[] = []
let paginationToken: string | undefined
do {
const response = await this.disk.listAll(prefix, { recursive: true, paginationToken })
for (const item of response.objects) {
if (!item.isFile || !item.key.startsWith(prefix)) continue
files.push({ key: item.key, relative: item.key.slice(prefix.length) })
}
paginationToken = response.paginationToken
} while (paginationToken)
return files
}
private async moveDirectory(oldPath: string, newPath: string) {
await this.disk.put(this.directoryMarkerService.markerPath(newPath), '')
for (const file of await this.listDirectoryFiles(oldPath)) {
const target = this.pathService.join(newPath, file.relative)
const dir = this.pathService.dirname(target)
if (dir) await this.disk.put(this.directoryMarkerService.markerPath(dir), '')
await this.disk.move(file.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> {
if (await this.disk.exists(this.directoryMarkerService.markerPath(path))) return true
try {
const response = await this.disk.listAll(this.directoryPrefix(path))
return Array.from(response.objects).length > 0
} catch {
return false
}
}
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)
return this.getDirectoryEntry(path)
}
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,291 @@
import { createReadStream } from 'node:fs'
import drive from '@adonisjs/drive/services/main'
import {
DirExistsException,
DirNotFoundException,
FileNotFoundException,
} from '#exceptions/file_manager_exceptions'
import PathService from '#base_modules/FileManager/services/path_service'
import type { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
import type { StorageService } from '#base_modules/FileManager/services/storage_service/storage_service_interface'
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 listDirectoryFiles(path: string): Promise<{ key: string; relative: string }[]> {
const prefix = this.directoryPrefix(path)
const files: { key: string; relative: string }[] = []
let paginationToken: string | undefined
do {
const response = await this.disk.listAll(prefix, { recursive: true, paginationToken })
for (const item of response.objects) {
if (!item.isFile || !item.key.startsWith(prefix)) continue
files.push({ key: item.key, relative: item.key.slice(prefix.length) })
}
paginationToken = response.paginationToken
} while (paginationToken)
return files
}
private async moveDirectory(oldPath: string, newPath: string) {
await this.disk.put(this.directoryMarkerService.markerPath(newPath), '')
for (const file of await this.listDirectoryFiles(oldPath)) {
const target = this.pathService.join(newPath, file.relative)
const dir = this.pathService.dirname(target)
if (dir) await this.disk.put(this.directoryMarkerService.markerPath(dir), '')
await this.disk.move(file.key, target)
}
}
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> {
if (await this.disk.exists(this.directoryMarkerService.markerPath(path))) return true
try {
const response = await this.disk.listAll(this.directoryPrefix(path))
return Array.from(response.objects).length > 0
} catch {
return false
}
}
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 {
for (const file of await this.listDirectoryFiles(storagePath)) {
await this.disk.delete(file.key)
}
}
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)
return this.getDirectoryEntry(path)
}
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) }
}
}
@@ -0,0 +1,25 @@
import type { Readable } from 'node:stream'
import type { MultipartFile } from '@adonisjs/core/bodyparser'
import type { 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())
}
}
@@ -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
View File
@@ -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()])
@@ -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(),
})
)
@@ -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
View File
@@ -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)
}
}
+13
View File
@@ -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
View File
@@ -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')
}
}
+6
View File
@@ -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()),
})
)
@@ -0,0 +1,21 @@
export const PAGE_MEDIA_UPLOADS_PATH = '/'
export const PAGE_MEDIA_FILE_SIZE_LIMIT = '10mb'
export const PREVIEW_TOKEN_DEFAULT_TTL_SECONDS = 900
export const PREVIEW_TOKEN_MIN_TTL_SECONDS = 60
export const PREVIEW_TOKEN_MAX_TTL_SECONDS = 3600
export const BREADCRUMB_PAGE_TYPE_PARENTS: Record<string, string> = {
'blog-item': 'blog',
}
/**
* Pages per sitemap request. Large enough that most sites need one round trip, small enough
* that no single query is unbounded; the sitemap protocol's own limit is 50,000 URLs per file.
*/
export const SITEMAP_PAGE_SIZE = 1000
export const SITEMAP_MAX_PAGE_SIZE = 5000
@@ -0,0 +1,8 @@
export default [
{ slug: 'web_manager.view', name: 'Web Manager view' },
{ slug: 'web_manager.create', name: 'Web Manager create' },
{ slug: 'web_manager.update', name: 'Web Manager update drafts' },
{ slug: 'web_manager.publish', name: 'Web Manager publish' },
{ slug: 'web_manager.delete', name: 'Web Manager delete drafts and pages' },
{ slug: 'web_manager.settings', name: 'Web Manager manage settings' },
]
@@ -0,0 +1,50 @@
import type { HttpContext } from '@adonisjs/core/http'
import WebManagerService from '#base_modules/WebManager/services/web_manager_service'
import PreviewTokenService from '#base_modules/WebManager/services/preview_token_service'
import {
previewRequestValidator,
publicBlogListValidator,
publicPageValidator,
publicSitemapValidator,
} from '#base_modules/WebManager/validator/page_validator'
import { settingsQueryValidator } from '#base_modules/WebManager/validator/settings_validator'
export default class PublicWebManagerController {
private webManagerService = new WebManagerService()
private previewTokenService = new PreviewTokenService()
async blogList({ request }: HttpContext) {
const filters = await publicBlogListValidator.validate(request.qs())
return this.webManagerService.listPublicBlogPages(filters)
}
async sitemap({ request }: HttpContext) {
const filters = await publicSitemapValidator.validate(request.qs())
return this.webManagerService.listPublicSitemapPages(filters)
}
async resolve({ request, response }: HttpContext) {
const { locale, path } = await publicPageValidator.validate(request.qs())
const result = await this.webManagerService.resolvePublicPage(locale, path)
if (!result) return response.notFound({ message: 'Page not found' })
return response.ok(result)
}
async preview({ request, response }: HttpContext) {
const { token } = await previewRequestValidator.validate(request.qs())
const payload = this.previewTokenService.verify(token)
if (!payload) return response.notFound({ message: 'Preview token is invalid or expired' })
const result = await this.webManagerService.resolvePreviewPage(payload.pageId)
return response.ok(result)
}
async settings({ request, response }: HttpContext) {
const { locale } = await settingsQueryValidator.validate(request.qs())
if (!locale) return response.badRequest({ message: 'Locale is required' })
const settings = await this.webManagerService.getSettings(locale)
if (!settings) return response.notFound({ message: 'Settings not found' })
return response.ok({ settings })
}
}
@@ -0,0 +1,98 @@
import type { HttpContext } from '@adonisjs/core/http'
import WebManagerPolicy from '#base_modules/WebManager/policy/web_manager_policy'
import WebManagerService from '#base_modules/WebManager/services/web_manager_service'
import PagePayloadService from '#base_modules/WebManager/services/page_payload_service'
import PreviewTokenService from '#base_modules/WebManager/services/preview_token_service'
import {
createPageValidator,
listPagesValidator,
previewTokenValidator,
saveDraftValidator,
} from '#base_modules/WebManager/validator/page_validator'
import {
saveSettingsValidator,
settingsQueryValidator,
} from '#base_modules/WebManager/validator/settings_validator'
export default class WebManagerController {
private webManagerService = new WebManagerService()
private pagePayloadService = new PagePayloadService()
private previewTokenService = new PreviewTokenService()
async index({ request, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('view')
const filters = await listPagesValidator.validate(request.qs())
const pages = await this.webManagerService.listPages(filters)
return response.ok(pages.serialize())
}
async groupKeys({ response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('view')
const groupKeys = await this.webManagerService.listGroupKeys()
return response.ok({ data: groupKeys })
}
async store({ request, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('create')
const payload = this.pagePayloadService.parse(request)
const input = await createPageValidator.validate(payload)
const resolved = await this.pagePayloadService.resolveLocalFiles(request, input)
const page = await this.webManagerService.createDraft(resolved)
return response.created({ data: page })
}
async show({ params, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('view')
const page = await this.webManagerService.getPage(Number(params.id))
return response.ok({ data: page })
}
async saveDraft({ params, request, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('update')
const payload = this.pagePayloadService.parse(request)
const input = await saveDraftValidator.validate(payload)
const resolved = await this.pagePayloadService.resolveLocalFiles(request, input)
const page = await this.webManagerService.saveDraft(Number(params.id), resolved)
return response.ok({ data: page })
}
async publish({ params, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('publish')
const { page, revalidation } = await this.webManagerService.publish(Number(params.id))
return response.ok({ data: page, meta: { revalidation } })
}
async unpublish({ params, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('publish')
const { page, revalidation } = await this.webManagerService.unpublish(Number(params.id))
return response.ok({ data: page, meta: { revalidation } })
}
async destroy({ params, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('delete')
await this.webManagerService.discard(Number(params.id))
return response.noContent()
}
async createPreviewToken({ params, request, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('view')
await this.webManagerService.getPage(Number(params.id))
const { expiresIn } = await previewTokenValidator.validate(request.all())
const preview = this.previewTokenService.create(Number(params.id), expiresIn)
return response.created({ data: preview })
}
async getSettings({ request, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('view')
const { locale } = await settingsQueryValidator.validate(request.qs())
const settings = await this.webManagerService.getSettings(locale)
return response.ok({ data: settings })
}
async saveSettings({ request, response, bouncer }: HttpContext) {
await bouncer.with(WebManagerPolicy).authorize('manageSettings')
const input = await saveSettingsValidator.validate(request.all())
const { settings, revalidation } = await this.webManagerService.saveSettings(input)
return response.ok({ data: settings, meta: { revalidation } })
}
}
@@ -0,0 +1,15 @@
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import WebPage from '#base_modules/WebManager/model/web_page'
import type { BlockData, BlockType } from '#base_modules/WebManager/types/web_content'
import { WebComponentSchema } from '#database/schema'
export default class WebComponent extends WebComponentSchema {
static table = 'web_components'
declare type: BlockType
declare data: BlockData
@belongsTo(() => WebPage, { foreignKey: 'pageId' })
declare page: BelongsTo<typeof WebPage>
}
@@ -0,0 +1,20 @@
import { hasMany, hasOne } from '@adonisjs/lucid/orm'
import type { HasMany, HasOne } from '@adonisjs/lucid/types/relations'
import WebComponent from '#base_modules/WebManager/model/web_component'
import type { Hero, PageType } from '#base_modules/WebManager/types/web_content'
import type { PageStatus } from '#base_modules/WebManager/types/page_status'
import { WebPageSchema } from '#database/schema'
export default class WebPage extends WebPageSchema {
static table = 'web_pages'
declare status: PageStatus
declare pageType: PageType
declare hero: Hero | null
@hasMany(() => WebComponent, { foreignKey: 'pageId' })
declare components: HasMany<typeof WebComponent>
@hasOne(() => WebPage, { foreignKey: 'draftOfId' })
declare draft: HasOne<typeof WebPage>
}
@@ -0,0 +1,10 @@
import type { SiteSettingsContent } from '#base_modules/WebManager/types/web_content'
import { WebSettingSchema } from '#database/schema'
export default class WebSetting extends WebSettingSchema {
static table = 'web_settings'
declare header: SiteSettingsContent['header']
declare footer: SiteSettingsContent['footer']
declare legal: SiteSettingsContent['legal']
}
@@ -0,0 +1,36 @@
import { BasePolicy } from '@adonisjs/bouncer'
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
import Admin from '#base_modules/Admin/model/admin'
import { getAdminPermissionSlugs } from '#services/permission_service'
export default class WebManagerPolicy extends BasePolicy {
view(user: Admin): Promise<AuthorizerResponse> {
return this.hasPermission(user, 'web_manager.view')
}
create(user: Admin): Promise<AuthorizerResponse> {
return this.hasPermission(user, 'web_manager.create')
}
update(user: Admin): Promise<AuthorizerResponse> {
return this.hasPermission(user, 'web_manager.update')
}
publish(user: Admin): Promise<AuthorizerResponse> {
return this.hasPermission(user, 'web_manager.publish')
}
delete(user: Admin): Promise<AuthorizerResponse> {
return this.hasPermission(user, 'web_manager.delete')
}
manageSettings(user: Admin): Promise<AuthorizerResponse> {
return this.hasPermission(user, 'web_manager.settings')
}
private async hasPermission(user: Admin, permission: string) {
if (!user) return false
const permissions = await getAdminPermissionSlugs(user)
return permissions.has(permission)
}
}
@@ -0,0 +1,34 @@
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
const WebManagerController = () =>
import('#base_modules/WebManager/controller/web_manager_controller')
const PublicWebManagerController = () =>
import('#base_modules/WebManager/controller/public_web_manager_controller')
router
.group(() => {
router.get('/blog/pages', [PublicWebManagerController, 'blogList'])
router.get('/pages/sitemap', [PublicWebManagerController, 'sitemap'])
router.get('/pages/resolve', [PublicWebManagerController, 'resolve'])
router.get('/pages/preview', [PublicWebManagerController, 'preview'])
router.get('/settings', [PublicWebManagerController, 'settings'])
})
.prefix('/web-manager/public')
router
.group(() => {
router.get('/pages', [WebManagerController, 'index'])
router.get('/pages/group-keys', [WebManagerController, 'groupKeys'])
router.post('/pages', [WebManagerController, 'store'])
router.get('/pages/:id', [WebManagerController, 'show'])
router.put('/pages/:id/draft', [WebManagerController, 'saveDraft'])
router.post('/pages/:id/publish', [WebManagerController, 'publish'])
router.post('/pages/:id/unpublish', [WebManagerController, 'unpublish'])
router.post('/pages/:id/preview-token', [WebManagerController, 'createPreviewToken'])
router.delete('/pages/:id', [WebManagerController, 'destroy'])
router.get('/settings', [WebManagerController, 'getSettings'])
router.put('/settings', [WebManagerController, 'saveSettings'])
})
.prefix('/web-manager')
.use([middleware.auth()])
@@ -0,0 +1,50 @@
import { BREADCRUMB_PAGE_TYPE_PARENTS } from '#base_modules/WebManager/config/general'
/**
* Utility functions that identify candidate pages for buildBreadcrumbs database queries.
* Breadcrumbs climb hierarchically from a target page to the home page.
* To avoid querying all published pages in a locale, queries are filtered down to two
* predictable sets of candidates:
* 1. Path-based ancestors: The small chain of parent paths above the target page.
* 2. Group-key ancestors: Static relationships where pages belong to a specific parent
* type regardless of location (e.g., blog-item under blog).
*/
export const BREADCRUMB_PARENT_GROUP_KEYS = [
...new Set(Object.values(BREADCRUMB_PAGE_TYPE_PARENTS)),
]
/**
* The paths a page can climb to, nearest parent first, ending at the home page (`''`).
*
* 'blog/post/hello' → ['blog/post', 'blog', '']
* 'about' → ['']
* '' → ['']
*
* The page's own path is never included — a page cannot be its own parent.
*/
export function ancestorPaths(path: string) {
const segments = path.split('/').filter(Boolean)
const paths: string[] = []
for (let depth = segments.length - 1; depth >= 1; depth--) {
paths.push(segments.slice(0, depth).join('/'))
}
paths.push('')
return paths
}
/**
* True when `path` is an already loaded path or an ancestor of one. Only false after a
* group-key jump to another branch, never during a path climb.
*/
export function ancestorsAlreadyLoaded(path: string, loadedPaths: Iterable<string>) {
for (const loaded of loadedPaths) {
// The trailing slash keeps 'blog' from matching 'blogging'; '' matches every path.
if (loaded === path || loaded.startsWith(path === '' ? '' : `${path}/`)) return true
}
return false
}
@@ -0,0 +1,103 @@
import sanitizeHtml from 'sanitize-html'
const ALLOWED_REL_VALUES = new Set(['nofollow', 'noopener', 'noreferrer'])
const ALLOWED_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:'])
const URL_KEYS = new Set(['href', 'url', 'externalUrl'])
const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = {
allowedTags: [
'a',
'b',
'blockquote',
'br',
'code',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'li',
'ol',
'p',
'pre',
's',
'span',
'strong',
'u',
'ul',
],
allowedAttributes: {
'*': ['class', 'title'],
'a': ['href', 'target', 'rel'],
},
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
allowProtocolRelative: true,
disallowedTagsMode: 'discard',
nonTextTags: [
'script',
'style',
'textarea',
'option',
'iframe',
'object',
'embed',
'form',
'svg',
'math',
],
transformTags: {
a: (tagName, attribs) => {
if (attribs.target !== undefined && !['_blank', '_self'].includes(attribs.target)) {
delete attribs.target
}
if (attribs.rel !== undefined) {
const rel = attribs.rel
.split(/\s+/)
.filter((value) => ALLOWED_REL_VALUES.has(value))
.join(' ')
if (rel) attribs.rel = rel
else delete attribs.rel
}
if (attribs.target === '_blank') attribs.rel = 'noopener noreferrer'
return { tagName, attribs }
},
},
}
export function sanitizeRichText(value: string) {
return sanitizeHtml(value, RICH_TEXT_OPTIONS)
}
export function sanitizeUrl(value: string) {
try {
const parsed = new URL(value, 'https://relative.invalid')
return ALLOWED_URL_PROTOCOLS.has(parsed.protocol) ? value : ''
} catch {
return ''
}
}
export function sanitizePayload(value: unknown, key = ''): unknown {
if (typeof value === 'string') {
return URL_KEYS.has(key) ? sanitizeUrl(value) : sanitizeRichText(value)
}
if (Array.isArray(value)) return value.map((item) => sanitizePayload(item))
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([childKey, childValue]) => [
childKey,
sanitizePayload(childValue, childKey),
])
)
}
return value
}
@@ -0,0 +1,127 @@
import type { HttpContext } from '@adonisjs/core/http'
import {
InvalidPagePayloadException,
PageMediaFileInvalidException,
PageMediaFileMissingException,
} from '#exceptions/web_manager_exceptions'
import FileManagerService from '#base_modules/FileManager/services/file_manager_service'
import {
PAGE_MEDIA_FILE_SIZE_LIMIT,
PAGE_MEDIA_UPLOADS_PATH,
} from '#base_modules/WebManager/config/general'
import type { JsonObject, PageInput } from '#base_modules/WebManager/types/web_manager'
type Request = HttpContext['request']
interface StoredFileRef {
name: string
ref: string
}
export default class PagePayloadService {
private fileManagerService = new FileManagerService()
/**
* Returns the page body from a save request. Multipart requests carry the page
* as a JSON string in the `payload` field (so value types survive transport);
* plain JSON requests are passed through unchanged.
*/
parse(request: Request): Record<string, unknown> {
const rawPayload = request.input('payload')
if (rawPayload === undefined) return request.all()
if (typeof rawPayload !== 'string') throw new InvalidPagePayloadException()
let parsed: unknown
try {
parsed = JSON.parse(rawPayload)
} catch {
throw new InvalidPagePayloadException()
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new InvalidPagePayloadException()
}
return parsed as Record<string, unknown>
}
/**
* Stores every uploaded file referenced by a `{ name, fileKey }` media value in
* the hero or component data, and replaces the value with `{ name, ref }` so
* only stored-file references are persisted.
*/
async resolveLocalFiles(request: Request, input: PageInput): Promise<PageInput> {
const resolvedByKey = new Map<string, StoredFileRef>()
const hero = input.hero
? ((await this.resolveValue(request, input.hero, resolvedByKey)) as JsonObject)
: input.hero
const components: PageInput['components'] = []
for (const component of input.components) {
components.push({
...component,
data: (await this.resolveValue(request, component.data, resolvedByKey)) as JsonObject,
})
}
return { ...input, hero, components }
}
private async resolveValue(
request: Request,
value: unknown,
resolvedByKey: Map<string, StoredFileRef>
): Promise<unknown> {
if (Array.isArray(value)) {
const items: unknown[] = []
for (const item of value) {
items.push(await this.resolveValue(request, item, resolvedByKey))
}
return items
}
if (value !== null && typeof value === 'object') {
if (this.isFileKeyValue(value)) {
return this.storeFile(request, value.fileKey, resolvedByKey)
}
const entries: Record<string, unknown> = {}
for (const [key, item] of Object.entries(value)) {
entries[key] = await this.resolveValue(request, item, resolvedByKey)
}
return entries
}
return value
}
private isFileKeyValue(value: object): value is { fileKey: string } {
return 'fileKey' in value && typeof (value as { fileKey: unknown }).fileKey === 'string'
}
private async storeFile(
request: Request,
fileKey: string,
resolvedByKey: Map<string, StoredFileRef>
): Promise<StoredFileRef> {
const cached = resolvedByKey.get(fileKey)
if (cached) return cached
const file = request.file(fileKey, { size: PAGE_MEDIA_FILE_SIZE_LIMIT })
if (!file) {
throw new PageMediaFileMissingException(fileKey)
}
if (!file.isValid) {
const message = file.errors.map((error: { message: string }) => error.message).join(', ')
throw new PageMediaFileInvalidException(fileKey, message)
}
const entry = await this.fileManagerService.writeFile(PAGE_MEDIA_UPLOADS_PATH, file)
const stored: StoredFileRef = { name: entry.name, ref: entry.path }
resolvedByKey.set(fileKey, stored)
return stored
}
}
@@ -0,0 +1,61 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { PREVIEW_TOKEN_DEFAULT_TTL_SECONDS } from '#base_modules/WebManager/config/general'
import env from '#start/env'
interface PreviewTokenPayload {
pageId: number
expiresAt: number
}
export default class PreviewTokenService {
create(pageId: number, expiresInSeconds = PREVIEW_TOKEN_DEFAULT_TTL_SECONDS) {
const payload: PreviewTokenPayload = {
pageId,
expiresAt: Math.floor(Date.now() / 1000) + expiresInSeconds,
}
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url')
return {
token: `${encodedPayload}.${this.sign(encodedPayload)}`,
expiresAt: new Date(payload.expiresAt * 1000).toISOString(),
}
}
verify(token: string): PreviewTokenPayload | null {
const [encodedPayload, suppliedSignature, ...extraParts] = token.split('.')
if (!encodedPayload || !suppliedSignature || extraParts.length > 0) return null
const expectedSignature = this.sign(encodedPayload)
const suppliedBuffer = Buffer.from(suppliedSignature)
const expectedBuffer = Buffer.from(expectedSignature)
if (
suppliedBuffer.length !== expectedBuffer.length ||
!timingSafeEqual(suppliedBuffer, expectedBuffer)
) {
return null
}
try {
const payload = JSON.parse(
Buffer.from(encodedPayload, 'base64url').toString('utf8')
) as Partial<PreviewTokenPayload>
if (
!Number.isInteger(payload.pageId) ||
typeof payload.expiresAt !== 'number' ||
payload.expiresAt <= Math.floor(Date.now() / 1000)
) {
return null
}
return payload as PreviewTokenPayload
} catch {
return null
}
}
private sign(payload: string) {
return createHmac('sha256', env.get('APP_KEY')).update(payload).digest('base64url')
}
}
@@ -0,0 +1,113 @@
import drive from '@adonisjs/drive/services/main'
import WebPage from '#base_modules/WebManager/model/web_page'
import { sanitizePayload } from '#base_modules/WebManager/services/content_sanitizer'
import type { JsonObject } from '#base_modules/WebManager/types/web_manager'
function publicHref(locale: string, path: string) {
return `/${locale}${path ? `/${path}` : ''}`
}
export default class PublicContentService {
/**
* Page ids referenced by internal links anywhere inside a stored content value. Collected
* before rendering so the link map can be fetched by id instead of loading every published
* page in the site.
*/
collectInternalPageIds(value: unknown, into: Set<string> = new Set<string>()) {
if (Array.isArray(value)) {
for (const item of value) this.collectInternalPageIds(item, into)
return into
}
if (!value || typeof value !== 'object') return into
const record = value as JsonObject
if (record.linkType === 'internal' && record.pageId !== undefined) {
into.add(String(record.pageId))
}
for (const child of Object.values(record)) this.collectInternalPageIds(child, into)
return into
}
/**
* Maps the given page ids to their public hrefs. Ids that are unknown or no longer
* published are simply absent, which `prepare` renders as an empty href — the same
* outcome as before, when the map only ever held published pages.
*/
async createPageLinkMap(pageIds: Iterable<string>) {
const ids = [...new Set(pageIds)]
.map((pageId) => Number(pageId))
.filter((pageId) => Number.isInteger(pageId) && pageId > 0)
if (ids.length === 0) return new Map<string, string>()
const linkedPages = await WebPage.query()
.where('status', 'published')
.whereIn('id', ids)
.select(['id', 'locale', 'path'])
return new Map(linkedPages.map((page) => [String(page.id), publicHref(page.locale, page.path)]))
}
/**
* Turns a stored content value (hero, block data, settings section) into its
* public shape: internal links gain an href, stored-file refs become URLs and
* rich text is sanitized. Works structurally, so it accepts any of the typed
* stored content entities.
*/
async prepare(value: object | null, pageLinks: Map<string, string>) {
if (!value) return null
const resolveLinks = (candidate: unknown): unknown => {
if (Array.isArray(candidate)) return candidate.map(resolveLinks)
if (!candidate || typeof candidate !== 'object') return candidate
const record = Object.fromEntries(
Object.entries(candidate).map(([key, child]) => [key, resolveLinks(child)])
) as JsonObject
if (record.linkType === 'internal' && record.pageId !== undefined) {
record.href = pageLinks.get(String(record.pageId)) ?? ''
}
return record
}
const resolved = await this.resolveStoredFiles(resolveLinks(value))
const sanitized = sanitizePayload(resolved) as JsonObject
return sanitized
}
/**
* Media values are persisted as `{ name, ref }` stored-file references;
* public payloads need browser-loadable URLs instead.
*/
private async resolveStoredFiles(candidate: unknown): Promise<unknown> {
if (Array.isArray(candidate)) {
return Promise.all(candidate.map((item) => this.resolveStoredFiles(item)))
}
if (!candidate || typeof candidate !== 'object') return candidate
const record = candidate as JsonObject
if (
typeof record.ref === 'string' &&
Object.keys(record).every((k) => k === 'ref' || k === 'name')
) {
try {
return await drive.use().getUrl(record.ref)
} catch {
return ''
}
}
const entries: JsonObject = {}
for (const [key, child] of Object.entries(record)) {
entries[key] = (await this.resolveStoredFiles(child)) as JsonObject[string]
}
return entries
}
}
@@ -0,0 +1,654 @@
import db from '@adonisjs/lucid/services/db'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import { DateTime } from 'luxon'
import {
PageNotFoundException,
PageNotPublishedException,
PageRouteConflictException,
} from '#exceptions/web_manager_exceptions'
import WebComponent from '#base_modules/WebManager/model/web_component'
import WebPage from '#base_modules/WebManager/model/web_page'
import WebSetting from '#base_modules/WebManager/model/web_setting'
import PublicContentService from '#base_modules/WebManager/services/public_content_service'
import WebRevalidationService from '#base_modules/WebManager/services/web_revalidation_service'
import {
BREADCRUMB_PAGE_TYPE_PARENTS,
SITEMAP_PAGE_SIZE,
} from '#base_modules/WebManager/config/general'
import {
ancestorPaths,
ancestorsAlreadyLoaded,
BREADCRUMB_PARENT_GROUP_KEYS,
} from '#base_modules/WebManager/services/breadcrumb_paths'
import type {
NormalizedPageInput,
PageInput,
SettingsInput,
} from '#base_modules/WebManager/types/web_manager'
import type { PageStatus } from '#base_modules/WebManager/types/page_status'
import type { ComponentContent, PageType } from '#base_modules/WebManager/types/web_content'
import {
parseComponentContent,
parseHero,
parseSiteSettingsContent,
} from '#base_modules/WebManager/validator/web_content_validator'
export default class WebManagerService {
private publicContentService = new PublicContentService()
private webRevalidationService = new WebRevalidationService()
async listPages(filters: {
locale?: string
status?: PageStatus
groupKey?: string
pageType?: PageType
search?: string
page?: number
perPage?: number
}) {
const query = WebPage.query()
.whereNull('draft_of_id')
.preload('draft')
.orderBy('updated_at', 'desc')
if (filters.locale) query.where('locale', filters.locale)
if (filters.status) query.where('status', filters.status)
if (filters.groupKey) query.where('group_key', filters.groupKey)
if (filters.pageType) query.where('page_type', filters.pageType)
if (filters.search) {
query.where((searchQuery) => {
searchQuery
.whereILike('title', `%${filters.search}%`)
.orWhereILike('group_key', `%${filters.search}%`)
.orWhereILike('path', `%${filters.search}%`)
.orWhereILike('seo_title', `%${filters.search}%`)
})
}
return query.paginate(filters.page ?? 1, filters.perPage ?? 25)
}
async listGroupKeys() {
const pages = await WebPage.query()
.select('group_key')
.whereNot('group_key', '')
.distinct()
.orderBy('group_key')
return pages.map((page) => page.groupKey)
}
async getPage(id: number) {
const page = await this.findPage(id)
return this.serializeAdminPage(page)
}
async createDraft(input: PageInput) {
const normalized = await this.normalizeInput(input)
await this.assertNewRouteAvailable(normalized.locale, normalized.path)
const page = await db.transaction(async (trx) => {
const createdPage = await WebPage.create(
{
status: 'draft',
draftOfId: null,
...this.pageFields(normalized),
publishedAt: null,
},
{ client: trx }
)
await this.replaceComponents(createdPage.id, normalized.components, trx)
return createdPage
})
return this.serializeAdminPage(page)
}
async saveDraft(id: number, input: PageInput) {
const normalized = await this.normalizeInput(input)
const draft = await this.ensureDraft(id)
await db.transaction(async (trx) => {
const lockedDraft = await WebPage.query({ client: trx })
.where('id', draft.id)
.whereNot('status', 'published')
.forUpdate()
.first()
if (!lockedDraft) throw new PageNotFoundException('Draft page')
if (lockedDraft.draftOfId === null) {
await this.assertNewRouteAvailable(normalized.locale, normalized.path, lockedDraft.id, trx)
}
lockedDraft.merge(this.pageFields(normalized))
await lockedDraft.save()
await this.replaceComponents(lockedDraft.id, normalized.components, trx)
})
return this.getPage(draft.id)
}
async publish(id: number) {
const published = await db.transaction(async (trx) => {
const target = await WebPage.query({ client: trx }).where('id', id).forUpdate().first()
if (!target) throw new PageNotFoundException()
let draft = target
if (target.draftOfId === null) {
const pendingDraft = await WebPage.query({ client: trx })
.where('draft_of_id', target.id)
.forUpdate()
.first()
if (pendingDraft) draft = pendingDraft
else if (target.status === 'published') return { page: target, structural: false }
}
if (draft.draftOfId === null) {
await this.assertPublishedRouteAvailable(draft.locale, draft.path, draft.id, trx)
draft.status = 'published'
draft.publishedAt = DateTime.now()
await draft.save()
return { page: draft, structural: true }
}
const original = await WebPage.query({ client: trx })
.where('id', draft.draftOfId)
.forUpdate()
.first()
if (!original) throw new PageNotFoundException('Published page')
await this.assertPublishedRouteAvailable(draft.locale, draft.path, original.id, trx)
const structural =
original.status !== 'published' ||
original.locale !== draft.locale ||
original.path !== draft.path ||
original.title !== draft.title ||
original.groupKey !== draft.groupKey ||
original.pageType !== draft.pageType
original.merge({
status: 'published',
locale: draft.locale,
path: draft.path,
groupKey: draft.groupKey,
pageType: draft.pageType,
title: draft.title,
hero: draft.hero,
seoTitle: draft.seoTitle,
seoDescription: draft.seoDescription,
publishedAt: DateTime.now(),
})
await original.save()
await WebComponent.query({ client: trx }).where('page_id', original.id).delete()
await WebComponent.query({ client: trx })
.where('page_id', draft.id)
.update({ page_id: original.id })
await draft.delete()
return { page: original, structural }
})
const revalidation = await this.revalidatePublishedPage(published.page, published.structural)
return { page: await this.getPage(published.page.id), revalidation }
}
async unpublish(id: number) {
const page = await db.transaction(async (trx) => {
const target = await WebPage.query({ client: trx }).where('id', id).forUpdate().first()
if (!target) throw new PageNotFoundException()
if (target.status !== 'published') throw new PageNotPublishedException()
target.status = 'unpublished'
await target.save()
return target
})
const revalidation = await this.webRevalidationService.revalidatePublicContent()
return { page: await this.getPage(page.id), revalidation }
}
private async revalidatePublishedPage(page: WebPage, structural: boolean) {
if (structural) {
return this.webRevalidationService.revalidatePublicContent()
}
return this.webRevalidationService.revalidatePage({
locale: page.locale,
path: page.path,
pageType: page.pageType,
translations: await this.findPublishedTranslations(page),
})
}
private async findPublishedTranslations(page: WebPage) {
if (!page.groupKey) return []
const siblings = await WebPage.query()
.where('status', 'published')
.where('group_key', page.groupKey)
.whereNot('id', page.id)
.select(['locale', 'path'])
return siblings.map((sibling) => ({ locale: sibling.locale, path: sibling.path }))
}
async discard(id: number) {
const page = await this.findPage(id)
const wasPublished = page.status === 'published'
await page.delete()
if (wasPublished) {
await this.webRevalidationService.revalidatePublicContent()
}
}
async getSettings(locale?: string) {
if (locale) return WebSetting.query().where('locale', locale).first()
return WebSetting.query().orderBy('locale')
}
async saveSettings(input: SettingsInput) {
const content = await parseSiteSettingsContent({
header: input.header,
footer: input.footer,
legal: input.legal,
})
const settings = await WebSetting.updateOrCreate(
{ locale: input.locale },
{ header: content.header, footer: content.footer, legal: content.legal }
)
const revalidation = await this.webRevalidationService.revalidateSettings(input.locale)
return { settings, revalidation }
}
async listPublicBlogPages(filters: { locale: string; page?: number; perPage?: number }) {
const paginated = await WebPage.query()
.where('status', 'published')
.where('locale', filters.locale)
.where('page_type', 'blog-item' satisfies PageType)
.orderBy('published_at', 'desc')
.orderBy('id', 'desc')
.paginate(filters.page ?? 1, filters.perPage ?? 24)
const noLinks = new Map<string, string>()
const items = await Promise.all(
paginated.all().map(async (page) => {
const heroMedia = page.hero?.media ?? null
const prepared = heroMedia
? await this.publicContentService.prepare({ media: heroMedia }, noLinks)
: null
return {
id: page.id,
locale: page.locale,
path: page.path,
groupKey: page.groupKey,
pageType: page.pageType,
title: page.title,
description: page.seoDescription,
media: prepared?.media ?? null,
publishedAt: page.publishedAt?.toISO() ?? null,
}
})
)
return {
items,
pagination: {
page: paginated.currentPage,
perPage: paginated.perPage,
total: paginated.total,
lastPage: paginated.lastPage,
},
}
}
async listPublicSitemapPages(filters: { page?: number; perPage?: number }) {
const paginated = await WebPage.query()
.where('status', 'published')
.whereNull('draft_of_id')
.select(['id', 'locale', 'path', 'group_key', 'page_type', 'published_at', 'updated_at'])
.orderBy('locale')
.orderBy('path')
.paginate(filters.page ?? 1, filters.perPage ?? SITEMAP_PAGE_SIZE)
return {
items: paginated.all().map((page) => ({
id: page.id,
locale: page.locale,
path: page.path,
groupKey: page.groupKey,
pageType: page.pageType,
publishedAt: page.publishedAt?.toISO() ?? null,
updatedAt: page.updatedAt?.toISO() ?? null,
})),
pagination: {
page: paginated.currentPage,
perPage: paginated.perPage,
total: paginated.total,
lastPage: paginated.lastPage,
},
}
}
async resolvePublicPage(locale: string, path: string) {
const page = await WebPage.query()
.where('status', 'published')
.where('locale', locale)
.where('path', this.normalizePath(path))
.first()
if (!page) return null
return this.serializePublicPage(page)
}
async resolvePreviewPage(id: number) {
const page = await this.findPage(id)
return this.serializePublicPage(page)
}
private async ensureDraft(id: number) {
return db.transaction(async (trx) => {
const page = await WebPage.query({ client: trx }).where('id', id).forUpdate().first()
if (!page) throw new PageNotFoundException()
if (page.status !== 'published') return page
const existingDraft = await WebPage.query({ client: trx })
.where('draft_of_id', page.id)
.first()
if (existingDraft) return existingDraft
const draft = await WebPage.create(
{
status: 'draft',
draftOfId: page.id,
locale: page.locale,
path: page.path,
groupKey: page.groupKey,
pageType: page.pageType,
title: page.title,
hero: page.hero,
seoTitle: page.seoTitle,
seoDescription: page.seoDescription,
publishedAt: null,
},
{ client: trx }
)
const components = await WebComponent.query({ client: trx })
.where('page_id', page.id)
.orderBy('sort_order')
if (components.length > 0) {
await WebComponent.createMany(
components.map((component) => ({
pageId: draft.id,
type: component.type,
data: component.data,
sortOrder: component.sortOrder,
})),
{ client: trx }
)
}
return draft
})
}
private async serializeAdminPage(page: WebPage) {
const components = await WebComponent.query()
.where('page_id', page.id)
.orderBy('sort_order')
.orderBy('id')
return {
page: page.serialize(),
components: components.map((component) => component.serialize()),
}
}
private async serializePublicPage(page: WebPage) {
const [components, settings, alternatives, breadcrumbs] = await Promise.all([
WebComponent.query().where('page_id', page.id).orderBy('sort_order').orderBy('id'),
WebSetting.query().where('locale', page.locale).first(),
WebPage.query()
.where('status', 'published')
.where('group_key', page.groupKey)
.select(['locale', 'path'])
.orderBy('locale'),
this.buildBreadcrumbs(page),
])
const linkedPageIds = new Set<string>()
this.publicContentService.collectInternalPageIds(page.hero, linkedPageIds)
for (const component of components) {
this.publicContentService.collectInternalPageIds(component.data, linkedPageIds)
}
if (settings) {
this.publicContentService.collectInternalPageIds(settings.header, linkedPageIds)
this.publicContentService.collectInternalPageIds(settings.footer, linkedPageIds)
this.publicContentService.collectInternalPageIds(settings.legal, linkedPageIds)
}
const pageLinks = await this.publicContentService.createPageLinkMap(linkedPageIds)
const hero = await this.publicContentService.prepare(page.hero, pageLinks)
const publicComponents = await Promise.all(
components.map(async (component) => ({
id: component.id,
type: component.type,
data: await this.publicContentService.prepare(component.data, pageLinks),
}))
)
const publicSettings = settings
? {
locale: settings.locale,
header: await this.publicContentService.prepare(settings.header, pageLinks),
footer: await this.publicContentService.prepare(settings.footer, pageLinks),
legal: await this.publicContentService.prepare(settings.legal, pageLinks),
}
: null
return {
page: {
id: page.id,
status: page.status,
locale: page.locale,
path: page.path,
groupKey: page.groupKey,
layout: page.pageType,
pageType: page.pageType,
title: page.title,
hero,
seo: { title: page.seoTitle || page.title, description: page.seoDescription },
publishedAt: page.publishedAt?.toISO() ?? null,
updatedAt: page.updatedAt?.toISO() ?? null,
},
components: publicComponents,
settings: publicSettings,
breadcrumbs,
languageAlternatives: alternatives.map((alternative) => ({
locale: alternative.locale,
path: alternative.path,
})),
}
}
private async buildBreadcrumbs(page: WebPage) {
const byPath = new Map<string, WebPage>()
const byGroupKey = new Map<string, WebPage>()
const loadedPaths: string[] = []
const loadCandidatesFor = async (target: WebPage) => {
if (ancestorsAlreadyLoaded(target.path, loadedPaths)) return
loadedPaths.push(target.path)
const candidates = await WebPage.query()
.where('status', 'published')
.where('locale', page.locale)
.whereNull('draft_of_id')
.where((scope) => {
scope.whereIn('path', ancestorPaths(target.path))
if (BREADCRUMB_PARENT_GROUP_KEYS.length > 0) {
scope.orWhereIn('group_key', BREADCRUMB_PARENT_GROUP_KEYS)
}
})
.select(['id', 'path', 'group_key', 'page_type', 'title'])
for (const candidate of candidates) {
if (!byPath.has(candidate.path)) byPath.set(candidate.path, candidate)
if (candidate.groupKey && !byGroupKey.has(candidate.groupKey)) {
byGroupKey.set(candidate.groupKey, candidate)
}
}
}
const resolveParent = (current: WebPage) => {
const parentGroupKey = BREADCRUMB_PAGE_TYPE_PARENTS[current.pageType]
if (parentGroupKey) {
const parent = byGroupKey.get(parentGroupKey)
if (parent && parent.id !== current.id) return parent
}
const segments = current.path.split('/').filter(Boolean)
for (let depth = segments.length - 1; depth >= 1; depth--) {
const ancestor = byPath.get(segments.slice(0, depth).join('/'))
if (ancestor && ancestor.id !== current.id) return ancestor
}
if (current.path !== '') {
const home = byPath.get('')
if (home && home.id !== current.id) return home
}
return null
}
const chain: Array<{ title: string; path: string }> = [{ title: page.title, path: page.path }]
const visited = new Set<number>([page.id])
let current: WebPage = page
await loadCandidatesFor(current)
while (true) {
const parent = resolveParent(current)
if (!parent || visited.has(parent.id)) break
visited.add(parent.id)
chain.unshift({ title: parent.title, path: parent.path })
current = parent
await loadCandidatesFor(current)
}
if (chain[0].path !== '') {
chain.unshift({ title: 'Home', path: '' })
}
return chain.map((crumb) => ({
label: crumb.title,
href: `/${page.locale}${crumb.path ? `/${crumb.path}` : ''}`,
}))
}
private async replaceComponents(
pageId: number,
components: ComponentContent[],
trx: TransactionClientContract
) {
await WebComponent.query({ client: trx }).where('page_id', pageId).delete()
if (components.length === 0) return
await WebComponent.createMany(
components.map((component, index) => ({
pageId,
type: component.type,
data: component.data,
sortOrder: index,
})),
{ client: trx }
)
}
private async normalizeInput(input: PageInput): Promise<NormalizedPageInput> {
let hero = null
if (input.hero) hero = await parseHero(input.hero)
const components = await Promise.all(
input.components.map(async (component, index) =>
parseComponentContent({ type: component.type.trim(), data: component.data }, index)
)
)
return {
locale: input.locale.trim(),
path: this.normalizePath(input.path),
groupKey: input.groupKey.trim(),
pageType: input.pageType,
title: input.title.trim(),
hero,
seoTitle: input.seoTitle?.trim() ?? '',
seoDescription: input.seoDescription?.trim() ?? '',
components,
}
}
private pageFields(input: NormalizedPageInput) {
return {
locale: input.locale,
path: input.path,
groupKey: input.groupKey,
pageType: input.pageType,
title: input.title,
hero: input.hero,
seoTitle: input.seoTitle,
seoDescription: input.seoDescription,
}
}
private normalizePath(path: string) {
return path.split('/').filter(Boolean).join('/')
}
private async findPage(id: number) {
const page = await WebPage.find(id)
if (!page) throw new PageNotFoundException()
return page
}
private async assertNewRouteAvailable(
locale: string,
path: string,
exceptId?: number,
trx?: TransactionClientContract
) {
const query = WebPage.query(trx ? { client: trx } : undefined)
.where('locale', locale)
.where('path', path)
.whereNull('draft_of_id')
if (exceptId) query.whereNot('id', exceptId)
if (await query.first()) throw new PageRouteConflictException(locale, path)
}
private async assertPublishedRouteAvailable(
locale: string,
path: string,
exceptId: number,
trx: TransactionClientContract
) {
const conflict = await WebPage.query({ client: trx })
.where('status', 'published')
.where('locale', locale)
.where('path', path)
.whereNot('id', exceptId)
.first()
if (conflict) throw new PageRouteConflictException(locale, path)
}
}
@@ -0,0 +1,178 @@
import logger from '@adonisjs/core/services/logger'
import env from '#start/env'
import type { PageType } from '#base_modules/WebManager/types/web_content'
export const WEB_MANAGER_CACHE_TAG = 'web-manager'
/**
* Tag builders mirroring `apps/web/web-runtime/api/cache-tags.ts`. The two files must
* stay in sync: the web app labels its cached fetches with these names and this service
* purges by the same names.
*/
export function pageCacheTag(locale: string, path: string) {
return `${WEB_MANAGER_CACHE_TAG}:page:${locale}:${path || '/'}`
}
export function settingsCacheTag(locale: string) {
return `${WEB_MANAGER_CACHE_TAG}:settings:${locale}`
}
export function blogCacheTag(locale: string) {
return `${WEB_MANAGER_CACHE_TAG}:blog:${locale}`
}
export const REVALIDATION_MAX_ATTEMPTS = 3
export const REVALIDATION_TIMEOUT_MS = 5000
export const REVALIDATION_RETRY_BASE_DELAY_MS = 1000
/**
* `purged` — the web app confirmed the purge.
* `retrying` — the first attempt failed and retries are running in the background;
* until one succeeds the public site serves cached content up to its `revalidate`
* window (60s, see `apps/web/web-runtime/api/web-runtime-api.ts`).
* `disabled` — no `WEB_APP_URL`/`WEB_REVALIDATE_SECRET`, so nothing was sent.
*/
export type RevalidationStatus = 'purged' | 'retrying' | 'disabled'
export interface RevalidationResult {
status: RevalidationStatus
tags: string[]
}
export interface PageRevalidationTarget {
locale: string
path: string
pageType: PageType
translations: Array<{ locale: string; path: string }>
}
const pendingRetries = new Set<Promise<unknown>>()
export async function flushPendingRevalidations() {
while (pendingRetries.size > 0) {
await Promise.all([...pendingRetries])
}
}
export default class WebRevalidationService {
/**
* Full-site revalidation. Reserved for structural changes — a page created, deleted or moved —
* where internal links resolved into other pages' HTML and descendants' breadcrumbs go
* stale in ways that would take a dependency graph to enumerate.
*/
async revalidatePublicContent() {
return this.revalidate([WEB_MANAGER_CACHE_TAG])
}
async revalidatePage(target: PageRevalidationTarget) {
const tags = new Set<string>([pageCacheTag(target.locale, target.path)])
for (const translation of target.translations) {
tags.add(pageCacheTag(translation.locale, translation.path))
}
if (target.pageType === 'blog-item') {
tags.add(blogCacheTag(target.locale))
}
return this.revalidate([...tags])
}
async revalidateSettings(locale: string) {
return this.revalidate([settingsCacheTag(locale)])
}
async revalidate(tags: string[]): Promise<RevalidationResult> {
const target = this.resolveTarget(tags)
if (!target) return { status: 'disabled', tags }
const failure = await this.attempt(tags, target.webAppUrl, target.secret)
if (!failure) return { status: 'purged', tags }
this.reportFailedAttempt(tags, 1, failure)
this.scheduleRetries(tags, target.webAppUrl, target.secret)
return { status: 'retrying', tags }
}
protected resolveTarget(tags: string[]): { webAppUrl: string; secret: string } | null {
const webAppUrl = env.get('WEB_APP_URL')
const secret = env.get('WEB_REVALIDATE_SECRET')
if (webAppUrl && secret) return { webAppUrl, secret }
const message = `Web revalidation of [${tags.join(', ')}] skipped: WEB_APP_URL or WEB_REVALIDATE_SECRET is not set`
if (env.get('NODE_ENV') === 'production') logger.warn(message)
else logger.debug(message)
return null
}
private reportFailedAttempt(tags: string[], attempt: number, failure: string) {
logger.warn(
`Web revalidation of [${tags.join(', ')}] failed on attempt ${attempt}/${REVALIDATION_MAX_ATTEMPTS}: ${failure}`
)
}
/** Returns a failure description, or `null` when the web app confirmed the purge. */
protected async attempt(tags: string[], webAppUrl: string, secret: string) {
try {
const response = await fetch(new URL('/api/revalidate', webAppUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Revalidate-Secret': secret,
},
body: JSON.stringify({ tags }),
signal: AbortSignal.timeout(REVALIDATION_TIMEOUT_MS),
})
return response.ok ? null : `responded with status ${response.status}`
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
}
private scheduleRetries(tags: string[], webAppUrl: string, secret: string) {
const retries = this.runRetries(tags, webAppUrl, secret)
pendingRetries.add(retries)
void retries.finally(() => pendingRetries.delete(retries))
}
/** Runs attempts 2..N with backoff, stopping at the first one that purges. */
private async runRetries(tags: string[], webAppUrl: string, secret: string) {
let failure = 'not attempted'
for (let attempt = 2; attempt <= REVALIDATION_MAX_ATTEMPTS; attempt++) {
await this.wait(this.backoffFor(attempt))
const attemptFailure = await this.attempt(tags, webAppUrl, secret)
if (!attemptFailure) {
logger.info(`Web revalidation of [${tags.join(', ')}] succeeded on attempt ${attempt}`)
return
}
failure = attemptFailure
this.reportFailedAttempt(tags, attempt, failure)
}
logger.error(
`Web revalidation of [${tags.join(', ')}] gave up after ${REVALIDATION_MAX_ATTEMPTS} attempts: ${failure}. ` +
'The public site serves cached content until its revalidate window expires (60s); ' +
'publishing again sends a fresh purge.'
)
}
protected backoffFor(attempt: number) {
const delay = REVALIDATION_RETRY_BASE_DELAY_MS * 2 ** (attempt - 2)
return delay + Math.random() * delay
}
protected wait(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms).unref?.()
})
}
}
@@ -0,0 +1,12 @@
/**
* Lifecycle of a page row:
*
* - `draft` — never published, or the working copy of a published page.
* - `published` — live on the public site.
* - `unpublished` — was live and was taken down; keeps its content, components and
* `published_at`, and keeps its route reserved so nothing else can claim it.
*
*/
export const PAGE_STATUSES = ['draft', 'published', 'unpublished'] as const
export type PageStatus = (typeof PAGE_STATUSES)[number]
@@ -0,0 +1,156 @@
export const PAGE_TYPES = ['landing', 'blog', 'blog-item'] as const
export type PageType = (typeof PAGE_TYPES)[number]
export const BLOCK_TYPES = [
'content',
'faq',
'media-content',
'articles',
'banner',
'media',
] as const
export type BlockType = (typeof BLOCK_TYPES)[number]
export const HERO_TYPES = ['none', 'low', 'middle', 'high'] as const
export type HeroType = (typeof HERO_TYPES)[number]
export const NAVIGATION_LINK_VARIANTS = [
'primary',
'secondary',
'outline',
'ghost',
'inline',
] as const
export type NavigationLinkVariant = (typeof NAVIGATION_LINK_VARIANTS)[number]
export const BANNER_STYLES = ['info', 'success', 'warning', 'error'] as const
export type BannerStyle = (typeof BANNER_STYLES)[number]
export const MEDIA_CONTENT_POSITIONS = ['left', 'right'] as const
export type MediaContentPosition = (typeof MEDIA_CONTENT_POSITIONS)[number]
export interface RichText {
content: string
}
export interface FaqItem {
question: string
answer: string
}
export interface MediaRef {
name: string
ref: string
}
export interface Media {
url: MediaRef | null
alt: string
width?: number
height?: number
mimeType?: string
}
interface NavigationLinkBase {
openInNewTab: boolean
label: string
variant: NavigationLinkVariant
}
export interface InternalNavigationLink extends NavigationLinkBase {
linkType: 'internal'
pageId: string
}
export interface ExternalNavigationLink extends NavigationLinkBase {
linkType: 'external'
externalUrl: string
}
export type NavigationLink = InternalNavigationLink | ExternalNavigationLink
export interface Hero {
type: HeroType
title?: string
subtitle?: string
content?: RichText
ctaItems: NavigationLink[]
media?: Media
}
export interface ContentColumn {
content: RichText
ctaItems: NavigationLink[]
}
export interface ContentBlockData {
title: string
subtitle: string
columns: ContentColumn[]
}
export interface FaqBlockData {
title: string
subtitle: string
items: FaqItem[]
}
export interface MediaContentBlockData {
title: string
mediaPosition: MediaContentPosition
media: Media
content: RichText
ctaItems: NavigationLink[]
}
export interface ArticlesBlockItem {
title: string
content: RichText
}
export interface ArticlesBlockData {
cta?: NavigationLink
title?: string
subtitle?: RichText
items: ArticlesBlockItem[]
}
export interface BannerBlockData {
style: BannerStyle
content: RichText
ctaItems: NavigationLink[]
}
export interface MediaBlockData {
media: Media
}
export type ComponentContent =
| { type: 'content'; data: ContentBlockData }
| { type: 'faq'; data: FaqBlockData }
| { type: 'media-content'; data: MediaContentBlockData }
| { type: 'articles'; data: ArticlesBlockData }
| { type: 'banner'; data: BannerBlockData }
| { type: 'media'; data: MediaBlockData }
export type BlockData = ComponentContent['data']
export interface SiteMenu {
title?: string
items: NavigationLink[]
}
export interface SiteSettingsContent {
header: {
menus: SiteMenu[]
ctaItems: NavigationLink[]
}
footer: {
copyright?: string
menus: SiteMenu[]
}
legal: {
privacyPath?: string
termsPath?: string
}
}
@@ -0,0 +1,39 @@
import type { ComponentContent, Hero, PageType } from '#base_modules/WebManager/types/web_content'
export type JsonObject = Record<string, unknown>
export interface ComponentInput {
type: string
data: JsonObject
}
export interface PageInput {
locale: string
path: string
groupKey: string
pageType: PageType
title: string
hero?: JsonObject | null
seoTitle?: string
seoDescription?: string
components: ComponentInput[]
}
export interface NormalizedPageInput {
locale: string
path: string
groupKey: string
pageType: PageType
title: string
hero: Hero | null
seoTitle: string
seoDescription: string
components: ComponentContent[]
}
export interface SettingsInput {
locale: string
header: JsonObject
footer: JsonObject
legal: JsonObject
}
@@ -0,0 +1,78 @@
import vine from '@vinejs/vine'
import {
PREVIEW_TOKEN_MAX_TTL_SECONDS,
PREVIEW_TOKEN_MIN_TTL_SECONDS,
SITEMAP_MAX_PAGE_SIZE,
} from '#base_modules/WebManager/config/general'
import {
componentSchema,
groupKeySchema,
localeSchema,
pathSchema,
} from '#base_modules/WebManager/validator/schemas'
import { PAGE_TYPES } from '#base_modules/WebManager/types/web_content'
import { PAGE_STATUSES } from '#base_modules/WebManager/types/page_status'
import { jsonObjectSchema } from '#validators/schemas/json_object_schema'
const pageFields = {
locale: localeSchema,
path: pathSchema,
groupKey: groupKeySchema,
pageType: vine.enum(PAGE_TYPES),
title: vine.string().trim().minLength(1).maxLength(255),
hero: jsonObjectSchema,
seoTitle: vine.string().trim().maxLength(255).optional(),
seoDescription: vine.string().trim().maxLength(2000).optional(),
components: vine.array(componentSchema).maxLength(200),
}
export const createPageValidator = vine.create(vine.object(pageFields))
export const saveDraftValidator = vine.create(vine.object(pageFields))
export const listPagesValidator = vine.create(
vine.object({
locale: localeSchema.optional(),
status: vine.enum(PAGE_STATUSES).optional(),
groupKey: groupKeySchema.optional(),
pageType: vine.enum(PAGE_TYPES).optional(),
search: vine.string().trim().maxLength(255).optional(),
page: vine.number().min(1).optional(),
perPage: vine.number().min(1).max(100).optional(),
})
)
export const publicPageValidator = vine.create(
vine.object({
locale: localeSchema,
path: pathSchema,
})
)
export const publicBlogListValidator = vine.create(
vine.object({
locale: localeSchema,
page: vine.number().min(1).optional(),
perPage: vine.number().min(1).max(100).optional(),
})
)
export const publicSitemapValidator = vine.create(
vine.object({
page: vine.number().min(1).optional(),
perPage: vine.number().min(1).max(SITEMAP_MAX_PAGE_SIZE).optional(),
})
)
export const previewTokenValidator = vine.create(
vine.object({
expiresIn: vine
.number()
.min(PREVIEW_TOKEN_MIN_TTL_SECONDS)
.max(PREVIEW_TOKEN_MAX_TTL_SECONDS)
.optional(),
})
)
export const previewRequestValidator = vine.create(
vine.object({ token: vine.string().trim().minLength(1) })
)
@@ -0,0 +1,32 @@
import { jsonObjectSchema } from '#validators/schemas/json_object_schema'
import vine from '@vinejs/vine'
export const localeSchema = vine
.string()
.trim()
.minLength(2)
.maxLength(16)
.regex(/^[a-z]{2,3}(?:-[A-Z]{2})?$/)
export const pathSchema = vine
.string()
.trim()
.maxLength(2048)
.regex(/^(?:(?:[a-z0-9]+(?:-[a-z0-9]+)*)(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*)?$/)
export const groupKeySchema = vine
.string()
.trim()
.minLength(1)
.maxLength(255)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
export const componentSchema = vine.object({
type: vine
.string()
.trim()
.minLength(1)
.maxLength(100)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
data: jsonObjectSchema,
})
@@ -0,0 +1,14 @@
import vine from '@vinejs/vine'
import { localeSchema } from '#base_modules/WebManager/validator/schemas'
import { jsonObjectSchema } from '#validators/schemas/json_object_schema'
export const settingsQueryValidator = vine.create(vine.object({ locale: localeSchema.optional() }))
export const saveSettingsValidator = vine.create(
vine.object({
locale: localeSchema,
header: jsonObjectSchema,
footer: jsonObjectSchema,
legal: jsonObjectSchema,
})
)
@@ -0,0 +1,225 @@
import vine, { errors } from '@vinejs/vine'
import { InvalidPagePayloadException } from '#exceptions/web_manager_exceptions'
import {
BANNER_STYLES,
HERO_TYPES,
MEDIA_CONTENT_POSITIONS,
NAVIGATION_LINK_VARIANTS,
} from '#base_modules/WebManager/types/web_content'
import type {
ComponentContent,
Hero,
SiteSettingsContent,
} from '#base_modules/WebManager/types/web_content'
const richTextSchema = () => vine.object({ content: vine.string() })
const faqItemSchema = () =>
vine.object({
question: vine.string(),
answer: vine.string(),
})
const mediaSchema = () =>
vine.object({
url: vine
.object({
name: vine.string(),
ref: vine.string(),
})
.nullable(),
alt: vine.string(),
width: vine.number().optional(),
height: vine.number().optional(),
mimeType: vine.string().optional(),
})
const navigationLinkBaseShape = () => ({
openInNewTab: vine.boolean(),
label: vine.string(),
variant: vine.enum(NAVIGATION_LINK_VARIANTS),
})
const isObjectWith = (value: unknown, key: string, expected: string) =>
vine.helpers.isObject(value) && value[key] === expected
const navigationLinkSchema = () =>
vine
.union([
vine.union.if(
(value) => isObjectWith(value, 'linkType', 'internal'),
vine.object({
...navigationLinkBaseShape(),
linkType: vine.literal('internal'),
pageId: vine.string().trim().minLength(1),
})
),
vine.union.if(
(value) => isObjectWith(value, 'linkType', 'external'),
vine.object({
...navigationLinkBaseShape(),
linkType: vine.literal('external'),
externalUrl: vine.string().url(),
})
),
])
.otherwise((_, field) => {
field.report('Navigation link must be internal or external', 'navigationLink', field)
})
const heroSchema = () =>
vine.object({
type: vine.enum(HERO_TYPES),
title: vine.string().optional(),
subtitle: vine.string().optional(),
content: richTextSchema().optional(),
ctaItems: vine.array(navigationLinkSchema()),
media: mediaSchema().optional(),
})
const contentBlockDataSchema = () =>
vine.object({
title: vine.string(),
subtitle: vine.string(),
columns: vine.array(
vine.object({
content: richTextSchema(),
ctaItems: vine.array(navigationLinkSchema()),
})
),
})
const faqBlockDataSchema = () =>
vine.object({
title: vine.string(),
subtitle: vine.string(),
items: vine.array(faqItemSchema()),
})
const mediaContentBlockDataSchema = () =>
vine.object({
title: vine.string(),
mediaPosition: vine.enum(MEDIA_CONTENT_POSITIONS),
media: mediaSchema(),
content: richTextSchema(),
ctaItems: vine.array(navigationLinkSchema()),
})
const articlesBlockDataSchema = () =>
vine.object({
cta: navigationLinkSchema().optional(),
title: vine.string().optional(),
subtitle: richTextSchema().optional(),
items: vine.array(
vine.object({
title: vine.string(),
content: richTextSchema(),
})
),
})
const bannerBlockDataSchema = () =>
vine.object({
style: vine.enum(BANNER_STYLES),
content: richTextSchema(),
ctaItems: vine.array(navigationLinkSchema()),
})
const mediaBlockDataSchema = () =>
vine.object({
media: mediaSchema(),
})
const componentContentSchema = () =>
vine
.union([
vine.union.if(
(value) => isObjectWith(value, 'type', 'content'),
vine.object({ type: vine.literal('content'), data: contentBlockDataSchema() })
),
vine.union.if(
(value) => isObjectWith(value, 'type', 'faq'),
vine.object({ type: vine.literal('faq'), data: faqBlockDataSchema() })
),
vine.union.if(
(value) => isObjectWith(value, 'type', 'media-content'),
vine.object({ type: vine.literal('media-content'), data: mediaContentBlockDataSchema() })
),
vine.union.if(
(value) => isObjectWith(value, 'type', 'articles'),
vine.object({ type: vine.literal('articles'), data: articlesBlockDataSchema() })
),
vine.union.if(
(value) => isObjectWith(value, 'type', 'banner'),
vine.object({ type: vine.literal('banner'), data: bannerBlockDataSchema() })
),
vine.union.if(
(value) => isObjectWith(value, 'type', 'media'),
vine.object({ type: vine.literal('media'), data: mediaBlockDataSchema() })
),
])
.otherwise((_, field) => {
field.report('Unknown component type', 'componentType', field)
})
const siteMenuSchema = () =>
vine.object({
title: vine.string().optional(),
items: vine.array(navigationLinkSchema()),
})
const siteSettingsContentSchema = () =>
vine.object({
header: vine.object({
menus: vine.array(siteMenuSchema()),
ctaItems: vine.array(navigationLinkSchema()),
}),
footer: vine.object({
copyright: vine.string().optional(),
menus: vine.array(siteMenuSchema()),
}),
legal: vine.object({
privacyPath: vine.string().optional(),
termsPath: vine.string().optional(),
}),
})
const heroValidator = vine.create(heroSchema())
const componentContentValidator = vine.create(componentContentSchema())
const siteSettingsContentValidator = vine.create(siteSettingsContentSchema())
function describeError(context: string, error: unknown): string {
if (error instanceof errors.E_VALIDATION_ERROR) {
const issue = (error.messages as Array<{ field?: string; message?: string }>)[0]
const path = issue?.field ? ` at "${issue.field}"` : ''
return `${context}${path}: ${issue?.message ?? 'invalid value'}`
}
return context
}
async function runValidator<Output>(
context: string,
validator: { validate(value: unknown): Promise<Output> },
value: unknown
): Promise<Output> {
try {
return await validator.validate(value)
} catch (error) {
throw new InvalidPagePayloadException(describeError(context, error))
}
}
export async function parseHero(value: unknown): Promise<Hero> {
return runValidator('Invalid hero', heroValidator, value)
}
export async function parseComponentContent(
value: unknown,
index: number
): Promise<ComponentContent> {
return runValidator(`Invalid component #${index}`, componentContentValidator, value)
}
export async function parseSiteSettingsContent(value: unknown): Promise<SiteSettingsContent> {
return runValidator('Invalid settings', siteSettingsContentValidator, value)
}
+59
View File
@@ -0,0 +1,59 @@
import { HttpContext } from '@adonisjs/core/http'
import { getDatabaseTables, getColumnsForTable } from '#services/database_inspector'
import { mapDBTypeToTs } from '#services/module_generator/db_types'
import { generateModule } from '#services/module_generator/generate_module'
import {
ModuleAsset,
ModuleLayoutConfig,
ModuleTableConfig,
} from '#services/module_generator/types'
export default class MakeModuleController {
async tables({ response }: HttpContext) {
const tables = await getDatabaseTables()
return response.ok(tables)
}
async columns({ request, response }: HttpContext) {
const { table } = request.params()
const columns = await getColumnsForTable(table)
return response.ok({
columns: columns.map((col) => ({
name: col.column_name,
type: mapDBTypeToTs(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : ''),
isDateTime: ['created_at', 'updated_at'].includes(col.column_name),
})),
})
}
async generate({ request, response }: HttpContext) {
const { name, table, generate, form, datatable } = request.only([
'name',
'table',
'generate',
'form',
'datatable',
]) as {
name: string
table: string
generate: ModuleAsset[]
form: ModuleLayoutConfig | null
datatable: ModuleTableConfig | null
}
if (typeof name !== 'string' || !/^[a-z][a-zA-Z0-9]*$/.test(name)) {
return response.unprocessableEntity({ message: 'Invalid module name' })
}
if (typeof table !== 'string' || !/^[a-z_][a-z0-9_]*$/i.test(table)) {
return response.unprocessableEntity({ message: 'Invalid table name' })
}
if (!Array.isArray(generate)) {
return response.unprocessableEntity({ message: 'Invalid generate list' })
}
const moduleName = await generateModule({ name, table, generate, form, datatable })
return response.ok({ message: `Module "${moduleName}" created based on table "${table}"` })
}
}
@@ -0,0 +1,8 @@
import { Exception } from '@adonisjs/core/exceptions'
import type { HttpContext } from '@adonisjs/core/http'
export default class ApiException extends Exception {
async handle(error: this, ctx: HttpContext) {
ctx.response.status(error.status).send({ message: error.message, code: error.code })
}
}
@@ -0,0 +1,55 @@
import ApiException from '#exceptions/api_exception'
export class FilenameMissingExtensionException extends ApiException {
static status = 400
static code = 'E_FILE_NAME_MISSING_EXTENSION'
constructor(message: string = 'File name missing extension') {
super(message)
}
}
export class EntryNameNotChangedException extends ApiException {
static status = 400
static code = 'E_ENTRY_NAME_NOT_CHANGED'
constructor(message: string = 'Entry name was not changed') {
super(message)
}
}
export class EntryExistsException extends ApiException {
static status = 400
static code = 'E_ENTRY_ALREADY_EXISTS'
constructor(message: string = 'Entry already exists') {
super(message)
}
}
export class DirExistsException extends ApiException {
static status = 400
static code = 'E_DIRECTORY_ALREADY_EXISTS'
constructor(message: string = 'Directory already exists') {
super(message)
}
}
export class FileNotFoundException extends ApiException {
static status = 404
static code = 'E_FILE_NOT_FOUND'
constructor(message: string = 'File not found') {
super(message)
}
}
export class DirNotFoundException extends ApiException {
static status = 404
static code = 'E_DIR_NOT_FOUND'
constructor(message: string = 'Directory not found') {
super(message)
}
}
+28
View File
@@ -0,0 +1,28 @@
import app from '@adonisjs/core/services/app'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
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) {
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)
}
}
@@ -0,0 +1,55 @@
import ApiException from '#exceptions/api_exception'
export class PageNotFoundException extends ApiException {
static status = 404
static code = 'E_PAGE_NOT_FOUND'
constructor(resource: string = 'Page') {
super(`${resource} not found`)
}
}
export class PageRouteConflictException extends ApiException {
static status = 409
static code = 'E_PAGE_ROUTE_CONFLICT'
constructor(locale: string, path: string) {
super(`A page already uses /${locale}/${path}`)
}
}
export class PageNotPublishedException extends ApiException {
static status = 409
static code = 'E_PAGE_NOT_PUBLISHED'
constructor() {
super('Only a published page can be unpublished')
}
}
export class InvalidPagePayloadException extends ApiException {
static status = 422
static code = 'E_INVALID_PAGE_PAYLOAD'
constructor(message: string = 'Invalid page payload') {
super(message)
}
}
export class PageMediaFileMissingException extends ApiException {
static status = 422
static code = 'E_PAGE_MEDIA_FILE_MISSING'
constructor(fileKey: string) {
super(`Missing uploaded file for key "${fileKey}"`)
}
}
export class PageMediaFileInvalidException extends ApiException {
static status = 422
static code = 'E_PAGE_MEDIA_FILE_INVALID'
constructor(fileKey: string, message?: string) {
super(message || `Invalid uploaded file for key "${fileKey}"`)
}
}
+8
View File
@@ -0,0 +1,8 @@
interface Message422 {
message: string
rule: string
field: string
}
export interface Error422 {
errors: Message422[]
}
+25
View File
@@ -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()
}
}
@@ -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()
}
}
@@ -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
>
}
}
+17
View File
@@ -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'),
}
+99
View File
@@ -0,0 +1,99 @@
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',
'web_components',
'web_pages',
'web_settings',
])
.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))
}
}
})
}
@@ -0,0 +1,389 @@
import {
indentSource,
source,
SourceFile,
} from '#services/module_generator/builders/shared/source_file'
import {
ModuleAsset,
ModuleGeneratorConfig,
ModuleTableColumn,
ModuleTableConfig,
} from '#services/module_generator/types'
class DataTableSourceBuilder {
build(data: ModuleGeneratorConfig) {
const file = new SourceFile()
this.addImports(file, data)
file.addSection(this.buildColumnFilterPropsInterface())
file.addSection(this.buildDataTableComponent(data))
file.addSection(this.buildToolbarComponent(data))
file.addSection(this.buildColumnInputFilterComponent())
return file.toString()
}
private addImports(file: SourceFile, data: ModuleGeneratorConfig) {
const shouldAddEditForm = this.shouldAddEditForm(data.generate)
const icons = ['LoaderCircleIcon', 'SearchIcon', 'Settings2']
const metaImports = ['MODULE_PERMISSION_DELETE']
if (shouldAddEditForm) {
icons.push('SquarePen')
metaImports.unshift('MODULE_NAME_LABEL', 'MODULE_PERMISSION_CREATE')
metaImports.push('MODULE_PERMISSION_UPDATE')
}
file.addImports([
"import React from 'react';",
`
import {
Column,
ColumnDef,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
SortingState,
useReactTable,
} from '@tanstack/react-table';
`,
`import { ${icons.join(', ')} } from 'lucide-react';`,
"import { useIntl } from 'react-intl';",
"import { Badge } from '#shared/ui/Badge';",
"import { CopyButton } from '#shared/ui/CopyButton';",
"import { Button } from '#shared/ui/Button';",
`
import {
Card,
CardFooter,
CardHeader,
CardTable,
CardTitle,
CardToolbar,
} from '#shared/ui/Card';
`,
"import { DataGrid, useDataGrid, DataGridColumnHeader, DataGridColumnVisibility, DataGridPagination, DataGridTable } from '#shared/widgets/DataGrid';",
"import { Input, InputWrapper } from '#shared/ui/Input';",
"import { ScrollArea, ScrollBar } from '#shared/ui/ScrollArea';",
"import { Skeleton } from '#shared/ui/Skeleton';",
"import { mapTableStateToListQueryParams } from '#infrastructure/reactQuery';",
"import { useDebounceSearch } from '@crux-kit/hooks';",
"import { PermissionGate } from '#core/Permission';",
"import { useGetList } from '../../api/list';",
`import { ${metaImports.join(', ')} } from '../../config/meta';`,
"import { Entity } from '../../entities/Entity';",
"import { DeleteTrigger } from './DeleteTrigger';",
])
if (shouldAddEditForm) {
file.addImport("import { ManageTrigger } from './ManageTrigger';")
}
}
private buildColumnFilterPropsInterface() {
return `
interface IColumnFilterProps<TData, TValue> {
column: Column<TData, TValue>
};
`
}
private buildDataTableComponent(data: ModuleGeneratorConfig) {
return `
export const DataTable = () => {
const intl = useIntl();
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [search, setSearch] = React.useState<string>('');
const { handleChange } = useDebounceSearch({
minQueryNumber: 3,
delay: 400,
initialQuery: search,
onTimeOut: setSearch,
});
const [sorting, setSorting] = React.useState<SortingState>([]);
const { list, total, isFetching, isLoading, pagesCount } = useGetList(
mapTableStateToListQueryParams(pagination, sorting, search)
);
const columns = React.useMemo<ColumnDef<Entity>[]>(
() => [
${indentSource(this.createColumnsSource(data), 12)}
],
[intl]
);
const table = useReactTable({
columns,
autoResetPageIndex: false,
enableMultiSort: false,
data: list,
manualPagination: true,
pageCount: pagesCount,
getRowId: (row) => String(row.id),
state: {
pagination,
sorting,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<DataGrid
table={table}
recordCount={total || 0}
emptyMessage={intl.formatMessage({ id: 'TABLE.COMMON.NO_DATA_AVAILABLE' })}
loadingMode="skeleton"
isLoading={isLoading}
tableLayout={{
columnsDraggable: false,
columnsResizable: false,
width: 'auto',
columnsPinnable: true,
columnsMovable: true,
columnsVisibility: true,
cellBorder: true,
}}
>
<Card>
<CardHeader>
<CardTitle>
<InputWrapper>
{search.length > 0 && isFetching ? (
<LoaderCircleIcon className="size-4 animate-spin" />
) : (
<SearchIcon className="size-4" />
)}
<Input
type="text"
placeholder={intl.formatMessage({ id: 'ACTION.SEARCH' })}
onChange={(e) => handleChange(e.target.value)}
/>
</InputWrapper>
</CardTitle>
<Toolbar />
</CardHeader>
<CardTable>
<ScrollArea>
<DataGridTable />
<ScrollBar orientation="horizontal" />
</ScrollArea>
</CardTable>
<CardFooter>
<DataGridPagination />
</CardFooter>
</Card>
</DataGrid>
)
};
`
}
private buildToolbarComponent(data: ModuleGeneratorConfig) {
return `
const Toolbar = () => {
const { table } = useDataGrid();
const intl = useIntl();
return (
<CardToolbar>
<div className="flex flex-wrap items-center gap-2.5">
${this.shouldAddEditForm(data.generate) ? indentSource(this.buildCreateButton(), 14) : ''}
<DataGridColumnVisibility
table={table}
trigger={
<Button variant="outline">
<Settings2 />
{intl.formatMessage({ id: 'TABLE.COMMON.COLUMNS' })}
</Button>
}
/>
</div>
</CardToolbar>
);
};
`
}
private buildColumnInputFilterComponent() {
return `
const ColumnInputFilter = <TData, TValue>({ column }: IColumnFilterProps<TData, TValue>) => {
const intl = useIntl();
return (
<Input
placeholder={intl.formatMessage({ id: 'FILTER' })}
value={(column.getFilterValue() as string) ?? ''}
onChange={(event) => column.setFilterValue(event.target.value)}
className="h-9 w-full max-w-40"
/>
);
};
`
}
private buildCreateButton() {
return `
<PermissionGate requiredPermissions={MODULE_PERMISSION_CREATE}>
<ManageTrigger
id={null}
title={intl.formatMessage(
{ id: 'MODULE.COMMON.CREATE_NEW_ENTITY' },
{ entity: MODULE_NAME_LABEL }
)}
>
<Button>{intl.formatMessage({ id: 'ACTION.CREATE' })}</Button>
</ManageTrigger>
</PermissionGate>
`
}
private buildEditButton() {
return `
<PermissionGate requiredPermissions={MODULE_PERMISSION_UPDATE}>
<ManageTrigger title={\`Edit \${MODULE_NAME_LABEL\}\`} id={id}>
<Button variant="secondary" size="sm" shape="default" mode="icon">
<SquarePen />
</Button>
</ManageTrigger>
</PermissionGate>
`
}
private shouldAddEditForm(assets: ModuleAsset[]): boolean {
return assets.includes(ModuleAsset.FORM)
}
private createColumnsSource(ctx: ModuleGeneratorConfig) {
const data: ModuleTableConfig = ctx.datatable ?? { columns: [] }
const columns: string[] = []
data.columns
.filter((column) => column.hidden === false)
.forEach((column) => {
const name = column.value
if (column.isActionColumn) {
columns.push(this.createActionColumn(column, ctx))
return
}
if (name === 'id' || name === 'uuid') {
columns.push(this.createIdColumn(column))
return
}
columns.push(this.createBaseColumn(column))
})
return columns.map(source).join(',\n')
}
private createBaseColumn(data: ModuleTableColumn) {
const enableSorting = data.innerProps.sort ? 'true' : 'false'
return `
{
id: '${data.value}',
accessorFn: (row) => row.${data.value},
header: ({ column }) => (
<DataGridColumnHeader
title="${data.label}"
filter={<ColumnInputFilter column={column} />}
column={column}
/>
),
cell: ${source(this.createCellContent(data))},
enableSorting: ${enableSorting},
meta: {
cellClassName: '',
skeleton: <Skeleton className="h-4" />,
},
}
`
}
private createIdColumn(data: ModuleTableColumn) {
return `
{
id: '${data.value}',
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader title="${data.label}" column={column} />,
cell: ${source(this.createCellContent(data))},
enableSorting: true,
size: 80,
meta: {
cellClassName: '',
skeleton: <Skeleton className="h-4" />,
},
}
`
}
private createActionColumn(data: ModuleTableColumn, ctx: ModuleGeneratorConfig) {
const editButtonSource = this.shouldAddEditForm(ctx.generate) ? this.buildEditButton() : ''
return `
{
id: '${data.value}',
header: '${data.label}',
cell: (info) => {
const data = info.row.original;
const id = data.id;
return (
<div className="flex items-center gap-2 justify-end">
${editButtonSource ? indentSource(editButtonSource, 14) : ''}
<PermissionGate requiredPermissions={MODULE_PERMISSION_DELETE}>
<DeleteTrigger id={id} deleteTarget={id} />
</PermissionGate>
</div>
);
},
enableSorting: false,
size: 96,
meta: {
headerClassName: '',
skeleton: <Skeleton className="h-4" />,
},
}
`
}
private createCellContent(data: ModuleTableColumn) {
if (data.innerProps.copy) {
return `
(info) => {
const val = String(info.getValue());
return (
<div className="flex items-center justify-between">
<code className="line-clamp-1">{val}</code>
<CopyButton copyText={val} />
</div>
);
}
`
}
if (data.innerProps.badge) {
return `(info) => <Badge variant="primary" size="sm">{String(info.getValue())}</Badge>`
}
return '(info) => info.getValue()'
}
}
export default new DataTableSourceBuilder()
@@ -0,0 +1,670 @@
import {
toComboboxGroups,
toFlatOptions,
toMultiSelectOptions,
} from '#services/module_generator/builders/shared/field_option_groups'
import {
indentSource,
source,
SourceFile,
} from '#services/module_generator/builders/shared/source_file'
import {
ModuleFieldType,
ModuleGridCell,
ModuleLayoutConfig,
} from '#services/module_generator/types'
// Every component from '#shared/forms/composed' the builder is able to emit. Only the ones
// actually present in the generated source are imported, so the module passes `noUnusedLocals`.
const COMPOSED_COMPONENTS = [
'FormElement',
'FormGrid',
'GridElement',
'InputCalendar',
'InputCheckboxGroup',
'InputColorPicker',
'InputCombobox',
'InputCreatableCombobox',
'InputDate',
'InputDatePicker',
'InputDatePickerRange',
'InputDropzone',
'InputFile',
'InputMultiSelect',
'InputNumberCounter',
'InputPhone',
'InputRadioGroup',
'InputSelect',
'InputSwitch',
'InputText',
'InputTextarea',
'InputTime',
'InputTiptapEditor',
]
class FormSourceBuilder {
buildComponent(data: ModuleLayoutConfig) {
const file = new SourceFile()
const section = this.buildMainFormComponent(data)
file.addImports([
"import React from 'react';",
"import { useForm } from 'react-hook-form';",
"import { zodResolver } from '@hookform/resolvers/zod';",
"import { Form as FormProvider } from '#shared/ui/form';",
`import { ${this.collectComposedComponents(section).join(', ')} } from '#shared/forms/composed';`,
"import { FORM_ID } from './constants';",
"import { FormValues, SubmitFunc, validationSchema } from './form';",
])
file.addSection(section)
return file.toString()
}
private collectComposedComponents(section: string): string[] {
return COMPOSED_COMPONENTS.filter((name) => new RegExp(`<${name}\\b`).test(section))
}
buildDefinition(data: ModuleLayoutConfig) {
const file = new SourceFile()
file.addImports([
"import { UseFormReturn } from 'react-hook-form';",
"import z from 'zod';",
"import { Entity } from '../../../entities/Entity';",
])
if (data.grid.find((item) => item.body?.type === ModuleFieldType.PHONE)) {
file.addImport("import { e164ToMask } from '#shared/lib/phoneNumber';")
}
file.addSection(this.buildFormDefinition(data))
return file.toString()
}
private buildMainFormComponent(data: ModuleLayoutConfig) {
return `
export const MainForm: React.FC<{
initialValues: FormValues;
onSubmit: SubmitFunc;
}> = ({ initialValues, onSubmit }) => {
const form = useForm<FormValues>({
values: initialValues,
resolver: zodResolver(validationSchema),
});
return (
<FormProvider {...form}>
<FormElement
id={FORM_ID}
onSubmit={form.handleSubmit((values) => onSubmit(values, form))}
>
<FormGrid columns={${data.cols}}>
${indentSource(this.createFieldsSource(data), 16)}
</FormGrid>
</FormElement>
</FormProvider>
)
}
`
}
private buildFormDefinition(data: ModuleLayoutConfig) {
return `
export const validationSchema = ${source(this.createValidationSchema(data))};
export type FormValues = z.infer<typeof validationSchema>;
export type SubmitFunc = (values: FormValues, handlers: UseFormReturn<FormValues>) => void;
export function prepareInitialValues(data: Entity): FormValues {
if (data == null) return initialValues;
return ${this.createPreparedValues(data)}
}
export const initialValues: FormValues = ${this.createInitialValues(data)};
`
}
private createFieldsSource(formConfig: ModuleLayoutConfig): string {
return formConfig.grid
.map((cell) => this.mapGridCellToComponent(cell))
.filter((str): str is string => str !== null)
.map(source)
.join('\n')
}
private createInitialValues(formConfig: ModuleLayoutConfig): string {
const values = formConfig.grid
.filter((item) => item.isEmpty === false)
.map((item) => ({
name: item.body?.dbColumn || '',
type: item.body?.type || ModuleFieldType.TEXT,
}))
.map((item) => this.mapTypeToInitialValue(item).created)
.filter((str) => str.trim().length !== 0)
.join(',')
return `{ id: null, ${values} }`
}
private createValidationSchema(formConfig: ModuleLayoutConfig): string {
const schemaStr = formConfig.grid
.filter((item) => item.isEmpty === false)
.map((item) => ({
name: item.body?.dbColumn || '',
type: item.body?.type || ModuleFieldType.TEXT,
required: item.body?.required ?? false,
}))
.map(this.mapTypeToZod)
.filter((str) => str.trim().length !== 0)
.join(',')
return `
z.object({
id: z.number().nullable(),
${schemaStr}
})
`
}
private createPreparedValues(formConfig: ModuleLayoutConfig): string {
const values = formConfig.grid
.filter((item) => item.isEmpty === false)
.map((item) => ({
name: item.body?.dbColumn || '',
type: item.body?.type || ModuleFieldType.TEXT,
}))
.map((item) => this.mapTypeToInitialValue(item).prepared)
.filter((str) => str.trim().length !== 0)
.join(',')
return `{id: data?.id ?? null, ${values}}`
}
private mapTypeToInitialValue({ name, type }: { name: string; type: ModuleFieldType }): {
created: string
prepared: string
} {
if (name === 'id') {
return {
created: `id: null`,
prepared: `id: data?.id ?? null`,
}
}
switch (type) {
case ModuleFieldType.CHECKBOX:
case ModuleFieldType.RADIO:
case ModuleFieldType.TEXT:
case ModuleFieldType.EMAIL:
case ModuleFieldType.NUMBER:
case ModuleFieldType.PASSWORD:
case ModuleFieldType.TEXTAREA:
case ModuleFieldType.SELECT:
case ModuleFieldType.COMBOBOX:
case ModuleFieldType.CREATABLE_COMBOBOX:
case ModuleFieldType.EDITOR:
case ModuleFieldType.COLOR_PICKER:
case ModuleFieldType.NUMBER_COUNTER: {
return {
created: `${name}: ''`,
prepared: `${name}: data?.${name}?.toString() ?? ''`,
}
}
case ModuleFieldType.PHONE: {
return {
created: `${name}: ''`,
prepared: `${name}: data?.${name} ? e164ToMask(data?.${name}) : ''`,
}
}
case ModuleFieldType.MULTISELECT: {
return {
created: `${name}: []`,
prepared: `${name}: data?.${name} ?? []`,
}
}
case ModuleFieldType.FILE: {
return {
created: `${name}: null`,
prepared: `${name}: data?.${name} ?? null`,
}
}
case ModuleFieldType.DROPZONE: {
return {
created: `${name}: null`,
prepared: `${name}: data?.${name} ?? null`,
}
}
case ModuleFieldType.SWITCH: {
return {
created: `${name}: false`,
prepared: `${name}: data?.${name} ?? false`,
}
}
case ModuleFieldType.TIME_INPUT_ISO:
case ModuleFieldType.TIME_INPUT_HMS: {
return {
created: `${name}: ''`,
prepared: `${name}: data?.${name} ? data?.${name} : ''`,
}
}
case ModuleFieldType.DATE_INPUT: {
return {
created: `${name}: ''`,
prepared: `${name}: data?.${name} ? data?.${name} : ''`,
}
}
case ModuleFieldType.DATE_PICKER_RANGE: {
return {
created: `${name}: null`,
prepared: `${name}: null`,
}
}
case ModuleFieldType.DATE_PICKER_SINGLE:
case ModuleFieldType.CALENDAR: {
return {
created: `${name}: null`,
prepared: `${name}: data?.${name} ? new Date(data?.${name}) : null`,
}
}
default: {
return {
created: '',
prepared: '',
}
}
}
}
private mapTypeToZod({
name,
required,
type,
}: {
name: string
type: ModuleFieldType
required: boolean
}): string {
const withOptional = (schema: string) => {
return required ? schema : `${schema}.optional().nullable()`
}
const withOptionalString = (schema: string) => {
return required ? `${schema}.min(1)` : `${schema}.optional().nullable()`
}
if (name === 'id') {
return `id: z.number().nullable()`
}
if (name === 'uuid') {
return `uuid: z.coerce.string().nullable()`
}
switch (type) {
case ModuleFieldType.TEXT:
case ModuleFieldType.TEXTAREA: {
return `${name}: ${withOptionalString('z.coerce.string()')}`
}
case ModuleFieldType.CHECKBOX:
case ModuleFieldType.RADIO:
case ModuleFieldType.NUMBER:
case ModuleFieldType.PASSWORD:
case ModuleFieldType.SELECT:
case ModuleFieldType.COMBOBOX:
case ModuleFieldType.CREATABLE_COMBOBOX:
case ModuleFieldType.PHONE:
case ModuleFieldType.EDITOR:
case ModuleFieldType.COLOR_PICKER:
case ModuleFieldType.NUMBER_COUNTER: {
return `${name}: ${withOptional('z.coerce.string()')}`
}
case ModuleFieldType.EMAIL: {
const schema = required
? 'z.coerce.string().email()'
: 'z.coerce.string().email().or(z.literal(""))'
return `${name}: ${schema}`
}
case ModuleFieldType.MULTISELECT: {
const schema = required ? 'z.array(z.number()).min(1)' : 'z.array(z.number())'
return `${name}: ${schema}`
}
case ModuleFieldType.FILE: {
return `${name}: ${withOptional('z.union([z.null(), z.string(), z.instanceof(File)])')}`
}
case ModuleFieldType.DROPZONE: {
return `${name}: ${withOptional('z.union([z.null(), z.string(), z.instanceof(File)])')}`
}
case ModuleFieldType.SWITCH: {
return `${name}: z.boolean()`
}
case ModuleFieldType.DATE_PICKER_RANGE: {
const schema = withOptional(`
z.object({
from: z.date().optional().nullable(),
to: z.date().optional().nullable(),
})
`)
return `${name}: ${schema}`
}
case ModuleFieldType.DATE_PICKER_SINGLE:
case ModuleFieldType.CALENDAR: {
return `${name}: ${withOptional('z.date()')}`
}
case ModuleFieldType.DATE_INPUT: {
const schema = withOptional('z.string().datetime()')
return `${name}: ${schema}`
}
case ModuleFieldType.TIME_INPUT_ISO: {
return `${name}: ${withOptional('z.string().datetime()')}`
}
case ModuleFieldType.TIME_INPUT_HMS: {
return `${name}: ${withOptional('z.string().time()')}`
}
default:
return ``
}
}
private mapGridCellToComponent(data: ModuleGridCell) {
const colSpan = data.colSpan
if (!data.body || colSpan === 0) {
return null
}
const { label, options = [], required, type, placeholder, dbColumn: name } = data.body
switch (type) {
case ModuleFieldType.CHECKBOX:
return `
<GridElement colSpan={${colSpan}}>
<InputCheckboxGroup
name="${name}"
label="${label}"
options={${JSON.stringify(toFlatOptions(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.RADIO:
return `
<GridElement colSpan={${colSpan}}>
<InputRadioGroup
name="${name}"
label="${label}"
options={${JSON.stringify(toFlatOptions(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.TEXT:
return `
<GridElement colSpan={${colSpan}}>
<InputText
name="${name}"
type="text"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.PHONE:
return `
<GridElement colSpan={${colSpan}}>
<InputPhone
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.EMAIL:
return `
<GridElement colSpan={${colSpan}}>
<InputText
name="${name}"
type="email"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.NUMBER:
return `
<GridElement colSpan={${colSpan}}>
<InputText
name="${name}"
type="number"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.PASSWORD:
return `
<GridElement colSpan={${colSpan}}>
<InputText
name="${name}"
type="password"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.TEXTAREA:
return `
<GridElement colSpan={${colSpan}}>
<InputTextarea
name="${name}"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.FILE:
return `
<GridElement colSpan={${colSpan}}>
<InputFile
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.DROPZONE:
return `
<GridElement colSpan={${colSpan}}>
<InputDropzone
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.SELECT:
return `
<GridElement colSpan={${colSpan}}>
<InputSelect
name="${name}"
label="${label}"
placeholder="${placeholder}"
options={${JSON.stringify(toFlatOptions(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.MULTISELECT:
return `
<GridElement colSpan={${colSpan}}>
<InputMultiSelect
name="${name}"
label="${label}"
placeholder="${placeholder}"
options={${JSON.stringify(toMultiSelectOptions(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.COMBOBOX:
return `
<GridElement colSpan={${colSpan}}>
<InputCombobox
name="${name}"
label="${label}"
placeholder="${placeholder}"
groups={${JSON.stringify(toComboboxGroups(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.CREATABLE_COMBOBOX:
return `
<GridElement colSpan={${colSpan}}>
<InputCreatableCombobox
name="${name}"
label="${label}"
placeholder="${placeholder}"
options={${JSON.stringify(toFlatOptions(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.SWITCH:
return `
<GridElement colSpan={${colSpan}}>
<InputSwitch
name="${name}"
label="${label}"
required={${required}}
wrapperClassName="flex-col items-start"
/>
</GridElement>
`
case ModuleFieldType.DATE_PICKER_RANGE:
return `
<GridElement colSpan={${colSpan}}>
<InputDatePickerRange
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.DATE_PICKER_SINGLE:
return `
<GridElement colSpan={${colSpan}}>
<InputDatePicker
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.CALENDAR:
return `
<GridElement colSpan={${colSpan}}>
<InputCalendar
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.DATE_INPUT:
return `
<GridElement colSpan={${colSpan}}>
<InputDate
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.TIME_INPUT_ISO:
return `
<GridElement colSpan={${colSpan}}>
<InputTime
name="${name}"
format="ISO"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.TIME_INPUT_HMS:
return `
<GridElement colSpan={${colSpan}}>
<InputTime
name="${name}"
format="HMS"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.EDITOR:
return `
<GridElement colSpan={${colSpan}}>
<InputTiptapEditor
name="${name}"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.COLOR_PICKER:
return `
<GridElement colSpan={${colSpan}}>
<InputColorPicker
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.NUMBER_COUNTER:
return `
<GridElement colSpan={${colSpan}}>
<InputNumberCounter
name="${name}"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
default:
return `<GridElement colSpan={${colSpan}} />`
}
}
}
export default new FormSourceBuilder()
@@ -0,0 +1,42 @@
import { SourceFile } from '#services/module_generator/builders/shared/source_file'
class ListPageSourceBuilder {
build(params: { addTable: boolean }) {
const file = new SourceFile()
file.addImports([
"import React from 'react';",
"import { Container } from '#shared/widgets/common/Container';",
"import { PermissionPageGate } from '#core/Permission';",
"import { MODULE_PERMISSION_VIEW } from '../../config/meta';",
])
if (params.addTable) {
file.addImport("import { DataTable } from '../../view/widgets/DataTable';")
}
file.addSection(this.buildComponent(params.addTable ? '<DataTable />' : ''))
return file.toString()
}
private buildComponent(tableSource: string) {
return `
const ListPage: React.FC = () => {
return (
<React.Fragment>
<PermissionPageGate requiredPermissions={[MODULE_PERMISSION_VIEW]}>
<Container>
${tableSource}
</Container>
</PermissionPageGate>
</React.Fragment>
);
};
export default ListPage;
`
}
}
export default new ListPageSourceBuilder()
@@ -0,0 +1,221 @@
import { SourceFile } from '#services/module_generator/builders/shared/source_file'
import { ModuleFormContainer } from '#services/module_generator/types'
type DialogSize = 'default' | 'md' | 'fullscreen'
type SheetSide = 'top' | 'right' | 'bottom' | 'left'
interface ManageTriggerContainerConfig {
body: string
close: string
content: string
contentProps: string
description: string
disclosureName: string
footer: string
header: string
root: string
title: string
trigger: string
}
class ManageTriggerSourceBuilder {
build(container: ModuleFormContainer) {
if (this.isSheet(container)) {
return this.buildSheet(this.getSheetSide(container))
}
return this.buildDialog(this.getDialogSize(container))
}
private isSheet(
container: ModuleFormContainer
): container is Extract<ModuleFormContainer, `sheet-${string}`> {
return container.startsWith('sheet-')
}
private getSheetSide(container: Extract<ModuleFormContainer, `sheet-${string}`>): SheetSide {
switch (container) {
case 'sheet-top':
return 'top'
case 'sheet-right':
return 'right'
case 'sheet-bottom':
return 'bottom'
case 'sheet-left':
return 'left'
}
}
private getDialogSize(container: Extract<ModuleFormContainer, `dialog-${string}`>): DialogSize {
switch (container) {
case 'dialog-default':
return 'default'
case 'dialog-md':
return 'md'
case 'dialog-fullscreen':
return 'fullscreen'
}
}
private buildDialog(dialogSize: DialogSize) {
const file = new SourceFile()
file.addImports([
"import React from 'react';",
"import { LoaderCircleIcon } from 'lucide-react';",
"import { useIntl } from 'react-intl';",
"import { Button } from '#shared/ui/Button';",
`
import {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '#shared/ui/Dialog';
`,
"import { Skeleton } from '#shared/ui/Skeleton';",
"import { useDisclosure } from '@crux-kit/hooks';",
"import { useGetDetailed } from '../../api/detailed';",
"import { initialValues, FORM_ID, MainForm, prepareInitialValues, useSubmit } from '../forms/MainForm';",
])
file.addSection(
this.buildManageTriggerComponent({
body: 'DialogBody',
close: 'DialogClose',
content: 'DialogContent',
contentProps: `variant="${dialogSize}"`,
description: 'DialogDescription',
disclosureName: 'dialog',
footer: 'DialogFooter',
header: 'DialogHeader',
root: 'Dialog',
title: 'DialogTitle',
trigger: 'DialogTrigger',
})
)
return file.toString()
}
private buildSheet(side: SheetSide) {
const file = new SourceFile()
file.addImports([
"import React from 'react';",
"import { LoaderCircleIcon } from 'lucide-react';",
"import { useIntl } from 'react-intl';",
"import { Button } from '#shared/ui/Button';",
`
import {
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '#shared/ui/Sheet';
`,
"import { Skeleton } from '#shared/ui/Skeleton';",
"import { useDisclosure } from '@crux-kit/hooks';",
"import { useGetDetailed } from '../../api/detailed';",
"import { initialValues, FORM_ID, MainForm, prepareInitialValues, useSubmit } from '../forms/MainForm';",
])
file.addSection(
this.buildManageTriggerComponent({
body: 'SheetBody',
close: 'SheetClose',
content: 'SheetContent',
contentProps: `side="${side}" className="max-h-dvh"`,
description: 'SheetDescription',
disclosureName: 'sheet',
footer: 'SheetFooter',
header: 'SheetHeader',
root: 'Sheet',
title: 'SheetTitle',
trigger: 'SheetTrigger',
})
)
return file.toString()
}
private buildManageTriggerComponent(config: ManageTriggerContainerConfig) {
const bodyProps = config.body === 'SheetBody' ? ' className="overflow-y-auto"' : ''
return `
export const ManageTrigger: React.FC<
React.PropsWithChildren<{
title: string;
id: number | null;
}>
> = ({ id, children, title }) => {
const intl = useIntl();
const ${config.disclosureName} = useDisclosure(false);
const { data, isLoading } = useGetDetailed(
id == null || ${config.disclosureName}.isOpen === false ? undefined : { id: String(id) },
);
const { isSubmitting, submit } = useSubmit({ onSuccess: ${config.disclosureName}.close });
return (
<${config.root} open={${config.disclosureName}.isOpen} onOpenChange={${config.disclosureName}.toggle}>
<${config.trigger} asChild>{children}</${config.trigger}>
<${config.content} ${config.contentProps}>
<${config.header}>
<${config.title}>{title}</${config.title}>
<${config.description} className="sr-only"></${config.description}>
</${config.header}>
<${config.body}${bodyProps}>
{isLoading ? (
<div className="grid grid-cols-2 gap-5">
<Skeleton className="h-15" />
<Skeleton className="h-15" />
<Skeleton className="h-15" />
<Skeleton className="h-15" />
</div>
) : (
<MainForm
initialValues={data ? prepareInitialValues(data) : initialValues}
onSubmit={submit}
/>
)}
</${config.body}>
<${config.footer}>
<Button type="submit" form={FORM_ID} disabled={isSubmitting}>
{isSubmitting ? (
<span className="flex items-center gap-2">
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
{intl.formatMessage({ id: 'STATE.SAVING' })}...
</span>
) : (
intl.formatMessage({ id: 'ACTION.SAVE' })
)}
</Button>
<${config.close} asChild>
<Button variant="outline">
{intl.formatMessage({ id: 'ACTION.CANCEL' })}
</Button>
</${config.close}>
</${config.footer}>
</${config.content}>
</${config.root}>
);
};
`
}
}
export default new ManageTriggerSourceBuilder()
@@ -0,0 +1,30 @@
import { SourceFile } from '#services/module_generator/builders/shared/source_file'
class MenuSourceBuilder {
build() {
const file = new SourceFile()
file.addImports([
"import { NotepadText } from 'lucide-react'",
"import { IntlShape } from 'react-intl'",
"import { MenuItem } from '#app/config/types'",
"import { MODULE_NAME_LABEL, MODULE_PERMISSION_VIEW, MODULE_ROOT_PATH } from './meta'",
])
file.addSection(`
export default (intl: IntlShape): MenuItem => ({
title: intl.formatMessage({
id: \`MODULES.\${MODULE_NAME_LABEL.toUpperCase()}.TITLE\`,
defaultMessage: MODULE_NAME_LABEL,
}),
icon: NotepadText,
path: MODULE_ROOT_PATH,
permissions: [MODULE_PERMISSION_VIEW],
})
`)
return file.toString()
}
}
export default new MenuSourceBuilder()
@@ -0,0 +1,36 @@
import { SourceFile } from '#services/module_generator/builders/shared/source_file'
class ModuleConfigSourceBuilder {
build(
moduleId: string,
options?: {
includeMenu: boolean
}
) {
const file = new SourceFile()
file.addImports([
"import { AppModuleConfig } from '#app/services/modules/types';",
"import messages from './messages';",
"import routes from './routing';",
])
if (options?.includeMenu) file.addImport("import menu from './menu';")
file.addSection(`
export default {
id: '${moduleId}',
area: 'business',
routeScope: 'protected',
order: 100,
routes,
menu: ${options?.includeMenu ? 'menu' : '() => null'},
messages,
} satisfies AppModuleConfig;
`)
return file.toString()
}
}
export default new ModuleConfigSourceBuilder()
@@ -0,0 +1,50 @@
import { ModuleFieldOption } from '#services/module_generator/types'
interface FieldOption {
label: string
value: string
}
export interface ComboboxOptionGroup {
label?: string
options: FieldOption[]
}
export interface MultiSelectOptionGroup {
heading: string
options: FieldOption[]
}
export function hasGroupedOptions(options: ModuleFieldOption[]): boolean {
return options.some((option) => option.group?.trim())
}
export function toFlatOptions(options: ModuleFieldOption[]): FieldOption[] {
return options.map((option) => ({ label: option.label, value: option.value }))
}
export function toComboboxGroups(options: ModuleFieldOption[]): ComboboxOptionGroup[] {
const groups = new Map<string, ComboboxOptionGroup>()
for (const option of options) {
const key = option.group?.trim() ?? ''
const group = groups.get(key) ?? { label: key || undefined, options: [] }
group.options.push({ label: option.label, value: option.value })
groups.set(key, group)
}
return [...groups.values()]
}
export function toMultiSelectOptions(
options: ModuleFieldOption[]
): FieldOption[] | MultiSelectOptionGroup[] {
if (!hasGroupedOptions(options)) {
return toFlatOptions(options)
}
return toComboboxGroups(options).map((group) => ({
heading: group.label ?? '',
options: group.options,
}))
}
@@ -0,0 +1,65 @@
type SourceChunk = string | null | false | undefined
const EMPTY_JSX_TEXT_EXPRESSION_REGEX = /\{\s*'\s*'\s*\}/g
function normalizeSource(value: string): string {
const trimmed = value.replace(/^\n/, '').replace(/\n\s*$/, '')
const lines = trimmed.split('\n')
const indents = lines
.filter((line) => line.trim().length > 0)
.map((line) => line.match(/^\s*/)?.[0].length ?? 0)
const minIndent = indents.length > 0 ? Math.min(...indents) : 0
return lines
.map((line) => line.slice(Math.min(line.match(/^\s*/)?.[0].length ?? 0, minIndent)))
.join('\n')
.replace(EMPTY_JSX_TEXT_EXPRESSION_REGEX, '')
.trim()
}
export function source(value: string): string {
return normalizeSource(value)
}
export function indentSource(value: string, spaces: number): string {
const prefix = ' '.repeat(spaces)
return normalizeSource(value)
.split('\n')
.map((line) => (line.length > 0 ? `${prefix}${line}` : line))
.join('\n')
}
export class SourceFile {
private readonly imports = new Set<string>()
private readonly sections: string[] = []
addImport(value: SourceChunk): this {
if (value) {
this.imports.add(normalizeSource(value))
}
return this
}
addImports(values: SourceChunk[]): this {
values.forEach((value) => this.addImport(value))
return this
}
addSection(value: SourceChunk): this {
if (value) {
this.sections.push(normalizeSource(value))
}
return this
}
toString(): string {
return [Array.from(this.imports).join('\n'), this.sections.join('\n\n')]
.filter((section) => section.length > 0)
.join('\n\n')
}
}

Some files were not shown because too many files have changed in this diff Show More