diff --git a/apps/api/app/Controllers/Http/Admin/Shop/OrdersController.ts b/apps/api/app/Controllers/Http/Admin/Shop/OrdersController.ts new file mode 100644 index 00000000..9fff9373 --- /dev/null +++ b/apps/api/app/Controllers/Http/Admin/Shop/OrdersController.ts @@ -0,0 +1,15 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' +import Order from 'App/Models/Shop/Order' + +export default class OrdersController { + /** + * Order list + */ + public async index({ request, bouncer }: HttpContextContract) { + await bouncer.with('RolePolicy').authorize('permission', 'api::orders.index') + + const page = request.input('page', 1) + const limit = request.input('limit', 10) + return Order.query().paginate(page, limit) + } +} diff --git a/apps/api/app/Controllers/Http/Admin/Shop/ProductsController.ts b/apps/api/app/Controllers/Http/Admin/Shop/ProductsController.ts new file mode 100644 index 00000000..56135ade --- /dev/null +++ b/apps/api/app/Controllers/Http/Admin/Shop/ProductsController.ts @@ -0,0 +1,172 @@ +import { schema } from '@ioc:Adonis/Core/Validator' +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' +import Product from 'App/Models/Shop/Product' +import { + descriptionRules, + priceRules, + slugRules, + statusRules, + titleRules, +} from 'App/Validations/product' +import cacheData, { purgeCache } from 'App/Services/cacheData' + +export default class ProductsController { + private cachePrefix = 'products' + + /** + * Product list + */ + public async index({ request, response, bouncer }: HttpContextContract) { + await bouncer.with('RolePolicy').authorize('permission', 'api::shop::products.index') + const page = request.input('page', 1) + const limit = request.input('limit', 10) + const requestQs = request.qs() + const qs = JSON.stringify(requestQs) + const cacheKey = qs !== '{}' ? `${this.cachePrefix}:${qs}` : this.cachePrefix + + return await cacheData(cacheKey)(response)(async () => { + return await Product.query().preload('user').filter(requestQs).paginate(page, limit) + }) + } + + /** + * Show product details + */ + public async show({ params, response, bouncer }: HttpContextContract) { + await bouncer.with('RolePolicy').authorize('permission', 'api::shop::products.show') + + const data = await cacheData(`${this.cachePrefix}:${params?.id}`)(response)(async () => { + return await Product.find(params?.id) + }) + + return { + data, + } + } + + /** + * Create Product + */ + public async create({ auth, request, response, bouncer }: HttpContextContract) { + await bouncer.with('RolePolicy').authorize('permission', 'api::shop::products.create') + const payload = request.only(['title', 'description', 'slug', 'status', 'price']) + + const userId = auth.use('api').user?.id + + // Validation + const createProductSchema = schema.create({ + title: titleRules, + description: descriptionRules, + slug: slugRules(), + status: statusRules, + price: priceRules, + }) + + await request.validate({ schema: createProductSchema }) + + try { + const product = await Product.create({ + title: payload.title, + description: payload.description, + slug: payload.slug, + status: payload.status, + price: payload.price, + user_id: userId, + }) + + await this.purgeCache() + + return product + } catch (e) { + return response.badRequest({ + errors: [ + { + message: e.toString(), + }, + ], + }) + } + } + + /** + * Archive product + */ + public async archive({ params, response, bouncer }: HttpContextContract) { + await bouncer.with('RolePolicy').authorize('permission', 'api::shop::products.archive') + + try { + const product = await Product.findOrFail(params?.id) + await product.delete() + return response.noContent() + } catch (e) { + return response.badRequest({ + errors: [ + { + message: e.toString(), + }, + ], + }) + } + } + + /** + * Archived products + */ + public async archived({ request, bouncer }: HttpContextContract) { + await bouncer.with('RolePolicy').authorize('permission', 'api::shop::products.archived') + + const page = request.input('page', 1) + const limit = request.input('limit', 10) + + return await Product.query().onlyTrashed().filter(request.qs()).paginate(page, limit) + } + + /** + * Restore product from archived + */ + public async restore({ request, response, bouncer }: HttpContextContract) { + await bouncer.with('RolePolicy').authorize('permission', 'api::shop::products.restore') + const payload = request.only(['product_id']) + + try { + const product = await Product.withTrashed().where('id', payload.product_id).firstOrFail() + await product.restore() + return product + } catch (e) { + return response.badRequest({ + errors: [ + { + message: e.toString(), + }, + ], + }) + } + } + + /** + * Clear one product cache + */ + public async clearOneCache({ response, params, bouncer }: HttpContextContract) { + await bouncer.with('RolePolicy').authorize('permission', 'api::shop::products.clearCache') + await this.purgeCache(params?.id) + + return response.noContent() + } + + /** + * Clear products cache + */ + public async clearAllCache({ bouncer, response }: HttpContextContract) { + await bouncer.with('RolePolicy').authorize('permission', 'api::shop::products.clearCache') + await this.purgeCache() + + return response.noContent() + } + + /** + * Clear cache + */ + private async purgeCache(id?: string) { + purgeCache(id ? `${this.cachePrefix}:${id}` : `${this.cachePrefix}*`) + } +} diff --git a/apps/api/app/Controllers/Http/Shop/OrdersController.ts b/apps/api/app/Controllers/Http/Shop/OrdersController.ts new file mode 100644 index 00000000..43e56a22 --- /dev/null +++ b/apps/api/app/Controllers/Http/Shop/OrdersController.ts @@ -0,0 +1,15 @@ +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' +import Order from 'App/Models/Shop/Order' + +export default class OrdersController { + /** + * Guild list + */ + public async index({ auth, request }: HttpContextContract) { + const authId = auth.use('api').user?.id || '' + + const page = request.input('page', 1) + const limit = 10 + return Order.query().where('customer_id', authId).paginate(page, limit) + } +} diff --git a/apps/api/app/Models/Filters/ProductFilter.ts b/apps/api/app/Models/Filters/ProductFilter.ts new file mode 100644 index 00000000..cea9e0ce --- /dev/null +++ b/apps/api/app/Models/Filters/ProductFilter.ts @@ -0,0 +1,7 @@ +import { ModelQueryBuilderContract } from '@ioc:Adonis/Lucid/Orm' +import Product from '../Shop/Product' +import BasePowerFilter from './BasePowerFilter' + +export default class ProductFilter extends BasePowerFilter { + public $query: ModelQueryBuilderContract +} diff --git a/apps/api/app/Models/Shop/Order.ts b/apps/api/app/Models/Shop/Order.ts new file mode 100644 index 00000000..10f5ce4f --- /dev/null +++ b/apps/api/app/Models/Shop/Order.ts @@ -0,0 +1,37 @@ +import { DateTime } from 'luxon' +import { BaseModel, column } from '@ioc:Adonis/Lucid/Orm' + +export default class Order extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @column() + public customer: object + + @column() + public cart: object + + @column() + public payments: object + + @column() + public payment_status: string + + @column() + public total: number + + @column() + public subtotal: number + + @column() + public paid_total: number + + @column() + public discount_total: number + + @column.dateTime({ autoCreate: true }) + public createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + public updatedAt: DateTime +} diff --git a/apps/api/app/Models/Shop/Product.ts b/apps/api/app/Models/Shop/Product.ts new file mode 100644 index 00000000..e31ff569 --- /dev/null +++ b/apps/api/app/Models/Shop/Product.ts @@ -0,0 +1,47 @@ +import { DateTime } from 'luxon' +import { BaseModel, belongsTo, BelongsTo, column } from '@ioc:Adonis/Lucid/Orm' +import { Filterable } from '@ioc:Adonis/Addons/LucidFilter' +import { SoftDeletes } from '@ioc:Adonis/Addons/LucidSoftDeletes' +import { compose } from '@ioc:Adonis/Core/Helpers' +import ProductFilter from '../Filters/ProductFilter' +import User from '../User' + +export default class Product extends compose(BaseModel, Filterable, SoftDeletes) { + public static $filter = () => ProductFilter + + @column({ isPrimary: true }) + public id: number + + @column() + public title: string + + @column() + public description: string + + @column() + public slug: string + + @column() + public price: number + + @column() + public status: 'draft' | 'published' + + @column({ serializeAs: null }) + public user_id: number + + @belongsTo(() => User, { + foreignKey: 'user_id', + localKey: 'id' + }) + public user: BelongsTo + + @column.dateTime() + public deletedAt: DateTime | null + + @column.dateTime({ autoCreate: true }) + public createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + public updatedAt: DateTime +} diff --git a/apps/api/app/Validations/product.ts b/apps/api/app/Validations/product.ts new file mode 100644 index 00000000..41a5b689 --- /dev/null +++ b/apps/api/app/Validations/product.ts @@ -0,0 +1,15 @@ +import { schema, rules } from '@ioc:Adonis/Core/Validator' + +export const titleRules = schema.string({ trim: true }, [rules.maxLength(20)]) +export const descriptionRules = schema.string({ trim: true }, [rules.maxLength(1000)]) +export const slugRules = (id?: number) => { + let fieldRules = [rules.maxLength(50)] + if (id) { + fieldRules.push(rules.unique({ table: 'products', column: 'slug', whereNot: { id } })) + } else { + fieldRules.push(rules.unique({ table: 'products', column: 'slug' })) + } + return schema.string({ trim: true }, fieldRules) +} +export const statusRules = schema.string({ trim: true }, [rules.maxLength(10)]) +export const priceRules = schema.number([rules.range(0, 999999999)]) diff --git a/apps/api/database/migrations/shop/1667297197047_product.ts b/apps/api/database/migrations/shop/1667297197047_product.ts new file mode 100644 index 00000000..1a7b4a2f --- /dev/null +++ b/apps/api/database/migrations/shop/1667297197047_product.ts @@ -0,0 +1,29 @@ +import BaseSchema from '@ioc:Adonis/Lucid/Schema' + +export default class extends BaseSchema { + protected tableName = 'products' + + public async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + + table.string('title', 255).notNullable() + table.text('description', 'longtext') + table.string('slug', 50).notNullable().unique() + table.string('status', 10).notNullable() + table.integer('price').notNullable() + table.integer('user_id').unsigned().references('users.id').onDelete('cascade') + table.timestamp('deleted_at', { useTz: true }).nullable() + + /** + * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL + */ + table.timestamp('created_at', { useTz: true }) + table.timestamp('updated_at', { useTz: true }) + }) + } + + public async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/api/database/migrations/shop/1667480498405_orders.ts b/apps/api/database/migrations/shop/1667480498405_orders.ts new file mode 100644 index 00000000..8254882d --- /dev/null +++ b/apps/api/database/migrations/shop/1667480498405_orders.ts @@ -0,0 +1,38 @@ +import BaseSchema from '@ioc:Adonis/Lucid/Schema' + +export default class extends BaseSchema { + protected tableName = 'orders' + + public async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + + table.string('email', 100).notNullable() + table.string('currency_code', 10).notNullable() + table.string('payment_status').notNullable() + table.integer('total').notNullable() + table.integer('subtotal').notNullable() + table.integer('paid_total').notNullable() + table.integer('coupon').nullable() + table.integer('discount_total').nullable() + table.integer('tax_rate').nullable() + table.integer('tax_total').nullable() + table.integer('refunded_total').nullable() + table.integer('refundable_amount').nullable() + table.string('status').notNullable() + table.integer('cart_id').notNullable() + table.integer('customer_id').unsigned().references('users.id').onDelete('cascade') + + /** + * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL + */ + table.timestamp('created_at', { useTz: true }) + table.timestamp('updated_at', { useTz: true }) + table.timestamp('canceled_at', { useTz: true }) + }) + } + + public async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/api/database/seeders/MainSeeder/Permission.ts b/apps/api/database/seeders/MainSeeder/Permission.ts index 2c788733..e597457c 100644 --- a/apps/api/database/seeders/MainSeeder/Permission.ts +++ b/apps/api/database/seeders/MainSeeder/Permission.ts @@ -11,6 +11,11 @@ export default class extends BaseSeeder { { action: 'api::accounts.delete' }, { action: 'api::characters.index' }, { action: 'api::guilds.index' }, + { action: 'api::shop::product.index' }, + { action: 'api::shop::product.show' }, + { action: 'api::shop::product.create' }, + { action: 'api::shop::product.archive' }, + { action: 'api::shop::product.archived' }, ]) } } diff --git a/apps/api/database/seeders/Settings.ts b/apps/api/database/seeders/Settings.ts index da4269d5..c27ed72d 100644 --- a/apps/api/database/seeders/Settings.ts +++ b/apps/api/database/seeders/Settings.ts @@ -26,6 +26,15 @@ export default class extends BaseSeeder { port: '6900', }), }, + // Product settings + { + name: 'shop_currency', + value: JSON.stringify({ + symbol: '$', + name: 'United States Dollar', + abbreviation: 'USD', + }), + }, ]) } } diff --git a/apps/api/start/routes/admin.ts b/apps/api/start/routes/admin.ts index 51b348dc..106fc86b 100644 --- a/apps/api/start/routes/admin.ts +++ b/apps/api/start/routes/admin.ts @@ -50,6 +50,18 @@ Route.group(() => { Route.get('guilds', 'GuildsController.index') Route.get('guilds/total', 'GuildsController.total') Route.get('guilds/:id', 'GuildsController.show') + // Shop + // Products + Route.get('products', 'Shop/ProductsController.index') + Route.get('products/archived', 'Shop/ProductsController.archived') + Route.get('products/:id', 'Shop/ProductsController.show') + Route.post('products', 'Shop/ProductsController.create') + Route.post('products/restore', 'Shop/ProductsController.restore') + Route.delete('products/:id', 'Shop/ProductsController.archive') + Route.delete('cache/products', 'Shop/ProductsController.clearAllCache') + Route.delete('cache/products/:id', 'Shop/ProductsController.clearOneCache') + // Orders + Route.get('orders', 'Shop/OrdersController.index') }).namespace('App/Controllers/Http/Admin') }) .prefix('/admin/api')