diff --git a/app/Controllers/Http/v1/LocationController.ts b/app/Controllers/Http/v1/LocationController.ts new file mode 100644 index 00000000..b4225f51 --- /dev/null +++ b/app/Controllers/Http/v1/LocationController.ts @@ -0,0 +1,80 @@ +import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import { ApiDocument } from 'App/Helpers/Api/Document'; +import Location, { LocationStatus } from 'App/Models/Location'; +import LocationManager from 'App/Helpers/Managers/LocationManager'; + +// TODO(matthiasrohmer): Add permissions +export default class LocationController { + public async index(ctx: HttpContextContract) { + const manager: LocationManager = new LocationManager(ctx); + await manager.all(); + + return new ApiDocument(ctx, manager.toResources(), { + paginator: manager.paginator, + }); + } + + public async store(ctx: HttpContextContract) { + const manager: LocationManager = new LocationManager(ctx); + await manager.create(); + + return new ApiDocument(ctx, manager.toResources()); + } + + public async show(ctx: HttpContextContract) { + const manager: LocationManager = new LocationManager(ctx); + + manager.include = 'address,types,subjects'; + await manager.byId(); + + const location: Location = manager.instance; + const publishable = await location.publishable(); + + return new ApiDocument(ctx, manager.toResources(), { publishable }); + } + + public async update(ctx: HttpContextContract) { + const manager: LocationManager = new LocationManager(ctx); + await manager.update(); + + manager.include = 'address,types,subjects'; + const location: Location = await manager.byId(); + const publishable = await location.publishable(); + + if (publishable !== true) { + location.status = LocationStatus.DRAFT; + if (location.$isDirty) { + await location.save(); + } + } + + return new ApiDocument(ctx, manager.toResources(), { + publishable, + }); + } + + public async translate(ctx: HttpContextContract) { + const manager: LocationManager = new LocationManager(ctx); + await manager.byId(); + await manager.translate(); + + return new ApiDocument(ctx, manager.toResources()); + } + + // public async destroy(ctx: HttpContextContract) { + // const { params, auth } = ctx; + // if (!auth.user) { + // throw new UnauthorizedException(); + // } + + // const location = await Location.query() + // .preload('address') + // .where('cid', params.id) + // .firstOrFail(); + // const address = location.address; + + // await Promise.all([location.delete(), address.delete()]); + + // return new ApiDocument(ctx, {}, 'Location deleted successfully'); + // } +} diff --git a/app/Helpers/Managers/BaseManager.ts b/app/Helpers/Managers/BaseManager.ts index 819db6ef..85f9439c 100644 --- a/app/Helpers/Managers/BaseManager.ts +++ b/app/Helpers/Managers/BaseManager.ts @@ -1,8 +1,12 @@ import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; -import { LucidModel } from '@ioc:Adonis/Lucid/Model'; import Resource from 'App/Helpers/Api/Resource'; -import { LucidRow, ModelPaginatorContract } from '@ioc:Adonis/Lucid/Model'; +import { + LucidModel, + LucidRow, + ModelPaginatorContract, +} from '@ioc:Adonis/Lucid/Model'; import { RawBuilderContract } from '@ioc:Adonis/Lucid/DatabaseQueryBuilder'; +import { ModelQueryBuilderContract } from '@ioc:Adonis/Lucid/Orm'; interface OrderableInstruction { name: string; @@ -31,14 +35,10 @@ interface ManagerSettings { filters?: Filter[]; } -type Model<T extends LucidModel> = T; +export class BaseManager<ManagedModel extends LucidModel> { + public ManagedModel: ManagedModel; -export class BaseManager { - public ModelClass: Model<LucidModel>; - - public RessourceClass; - - public instances: Array<LucidRow> = []; + public instances: Array<InstanceType<ManagedModel>> = []; public paginator: ModelPaginatorContract<LucidRow>; @@ -60,13 +60,8 @@ export class BaseManager { queryId: 'id', }; - constructor( - ctx: HttpContextContract, - ModelClass: LucidModel, - ResourceClass = Resource - ) { - this.ModelClass = ModelClass; - this.RessourceClass = ResourceClass; + constructor(ctx: HttpContextContract, ManagedModel: ManagedModel) { + this.ManagedModel = ManagedModel; this.ctx = ctx; this.language = ctx.language as string; @@ -75,9 +70,9 @@ export class BaseManager { public query( options: { sort?: string; includes?: string; filter?: string } = {} - ) { - let query = this.ModelClass.query(); - if (this.ModelClass.$hasRelation('translations')) { + ): ModelQueryBuilderContract<ManagedModel> { + let query = this.ManagedModel.query() as any; + if (this.ManagedModel.$hasRelation('translations')) { query = query.preload('translations'); } @@ -196,7 +191,7 @@ export class BaseManager { this.paginator = result; this.paginator.baseUrl(this.ctx.request.completeUrl()); - this.instances = result.rows; + this.instances = result.all() as InstanceType<ManagedModel>[]; return result; } @@ -230,16 +225,16 @@ export class BaseManager { return this.instances[0]; } - public set instance(instance) { + public set instance(instance: InstanceType<ManagedModel>) { this.instances = [instance]; } public async create() { - return [new this.ModelClass()]; + return new this.ManagedModel(); } public async update() { - return this.instances; + return this.instance; } public async $validateTranslation() { @@ -255,12 +250,13 @@ export class BaseManager { } public async $saveTranslation(attributes) { - const translation = this.instance.translations.find((translation) => { + const instance = this.instance as any; + const translation = instance.translations.find((translation) => { return translation.language === attributes.language; }); if (!translation) { - await this.instance.related('translations').create(attributes); + await instance.related('translations').create(attributes); } else { translation.merge(attributes); await translation.save(); @@ -274,6 +270,30 @@ export class BaseManager { return this.byId(); } + public async $updateLinks(instance, links) { + if (links) { + await instance.load('links'); + + let index = 0; + while (instance.links[index] || links[index]) { + const link = instance.links[index]; + const url = links[index]; + + if (link && url) { + const link = instance.links[index]; + link.url = links[index]; + await link.save(); + } else if (!link && url) { + await instance.related('links').create({ url }); + } else if (link && !url) { + await link.delete(); + } + + index++; + } + } + } + private $toResource(instance): Resource { const resource = new Resource(instance); resource.boot(); diff --git a/app/Helpers/Managers/LocationManager.ts b/app/Helpers/Managers/LocationManager.ts new file mode 100644 index 00000000..0c4d915a --- /dev/null +++ b/app/Helpers/Managers/LocationManager.ts @@ -0,0 +1,132 @@ +import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import BaseManager from 'App/Helpers/Managers/BaseManager'; +import Location from 'App/Models/Location'; +import { + CreateLocationValidator, + UpdateLocationValidator, +} from 'App/Validators/v1/LocationValidator'; +import { LocationTranslationValidator } from 'App/Validators/v1/LocationTranslationValidator'; +import Database from '@ioc:Adonis/Lucid/Database'; +import Address from 'App/Models/Address'; + +export default class LocationManager extends BaseManager<typeof Location> { + public ManagedModel = Location; + + public settings = { + queryId: 'public_id', + orderableBy: [ + { + name: 'name', + query: Database.raw( + `(SELECT name FROM organizer_translations WHERE organizer_translations.organizer_id = organizers.id AND organizer_translations.language = '${this.language}')` + ), + }, + { + name: 'createdAt', + attribute: 'created_at', + }, + { + name: 'updatedAt', + attribute: 'updated_at', + }, + ], + }; + + public validators = { + translate: LocationTranslationValidator, + }; + + constructor(ctx: HttpContextContract) { + super(ctx, Location); + } + + public query() { + return super.query().preload('address'); + } + + private async $createAddress(location, attributes, trx) { + const address = new Address(); + await location.related(); + address.fill(attributes); + + address.useTransaction(trx); + await address.save(); + await location.related('address').associate(address); + + return address; + } + + public async create() { + const { attributes, relations } = await this.ctx.request.validate( + new CreateLocationValidator(this.ctx) + ); + + const location = new Location(); + await Database.transaction(async (trx) => { + location.useTransaction(trx); + await location.save(); + + await location.related('translations').create({ + name: attributes.name, + description: attributes.description, + language: this.language, + }); + + if (relations?.address) { + await this.$createAddress(location, relations.address.attributes, trx); + } + + await this.$updateLinks(location, relations?.links); + }); + + return await await this.byId(location.publicId); + } + + public async update() { + const { attributes, relations } = await this.ctx.request.validate( + new UpdateLocationValidator(this.ctx) + ); + + const location = await this.byId(); + await Database.transaction(async (trx) => { + location.useTransaction(trx); + if (location.$isDirty) { + await location.save(); + } + + if (relations?.address) { + if (location.address) { + location.address.merge(relations.address.attributes); + await location.address.save(); + } else { + await this.$createAddress( + location, + relations.address.attributes, + trx + ); + } + } + + await this.$updateLinks(location, relations?.links); + }); + + return await this.byId(location.publicId); + } + + public async translate() { + const attributes = await this.$validateTranslation(); + + // Creating an organizer translation without a name is forbidden, + // but initially creating one without a name is impossible. Hence fallback + // to the initial name + if (!attributes.name) { + attributes.name = this.instance?.translations?.find((translation) => { + return translation.name; + })?.name; + } + + await this.$saveTranslation(attributes); + + return this.byId(); + } +} diff --git a/app/Helpers/Managers/OrganizerManager.ts b/app/Helpers/Managers/OrganizerManager.ts index f2d92b58..12749d0b 100644 --- a/app/Helpers/Managers/OrganizerManager.ts +++ b/app/Helpers/Managers/OrganizerManager.ts @@ -1,4 +1,3 @@ -import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; import BaseManager from 'App/Helpers/Managers/BaseManager'; import Organizer, { OrganizerStatus } from 'App/Models/Organizer'; import { withTranslations } from 'App/Helpers/Utilities'; @@ -10,8 +9,8 @@ import { OrganizerTranslationValidator } from 'App/Validators/v1/OrganizerTransl import Address from 'App/Models/Address'; import Database from '@ioc:Adonis/Lucid/Database'; -export default class OrganizerManager extends BaseManager { - public ModelClass = Organizer; +export default class OrganizerManager extends BaseManager<typeof Organizer> { + public ManagedModel = Organizer; public settings = { queryId: 'public_id', @@ -81,7 +80,7 @@ export default class OrganizerManager extends BaseManager { translate: OrganizerTranslationValidator, }; - constructor(ctx: HttpContextContract) { + constructor(ctx) { super(ctx, Organizer); } @@ -109,30 +108,6 @@ export default class OrganizerManager extends BaseManager { } } - private async $updateLinks(organizer: Organizer, links) { - if (links) { - await organizer.load('links'); - - let index = 0; - while (organizer.links[index] || links[index]) { - const link = organizer.links[index]; - const url = links[index]; - - if (link && url) { - const link = organizer.links[index]; - link.url = links[index]; - await link.save(); - } else if (!link && url) { - await organizer.related('links').create({ url }); - } else if (link && !url) { - await link.delete(); - } - - index++; - } - } - } - public async create() { const { attributes, relations } = await this.ctx.request.validate( new CreateOrganizerValidator(this.ctx) @@ -159,7 +134,7 @@ export default class OrganizerManager extends BaseManager { await this.$updateLinks(organizer, relations?.links); }); - return await this.byId(organizer.publicId); + return await await this.byId(organizer.publicId); } public async update() { @@ -167,7 +142,7 @@ export default class OrganizerManager extends BaseManager { new UpdateOrganizerValidator(this.ctx) ); - const organizer = (await this.byId()) as Organizer; + const organizer = await this.byId(); await Database.transaction(async (trx) => { organizer.homepage = attributes?.homepage || organizer.homepage; organizer.phone = attributes?.phone || organizer.phone; @@ -207,9 +182,9 @@ export default class OrganizerManager extends BaseManager { // but initially creating one without a name is impossible. Hence fallback // to the initial name if (!attributes.name) { - attributes.name = this.instance.translations.find((translation) => { + attributes.name = this.instance?.translations?.find((translation) => { return translation.name; - }).name; + })?.name; } await this.$saveTranslation(attributes); diff --git a/app/Helpers/Utilities.ts b/app/Helpers/Utilities.ts index c1f3728b..1aeca2dc 100644 --- a/app/Helpers/Utilities.ts +++ b/app/Helpers/Utilities.ts @@ -1,3 +1,6 @@ +import Resource from 'App/Helpers/Api/Resource'; +import { validator } from '@ioc:Adonis/Core/Validator'; + export function withTranslations(query) { return query.preload('translations'); } @@ -11,3 +14,43 @@ export function findTranslation(translations, language?) { return translation.language === language; }); } + +export async function publishable( + instance, + PublishableValidator, + PublishableTranslationValidator? +) { + const resource = new Resource(instance).boot().toObject(); + + const errors = {}; + try { + await validator.validate({ + schema: new PublishableValidator().schema, + data: resource, + }); + } catch (e) { + Object.assign(errors, e.messages); + } + + if (PublishableTranslationValidator) { + // Use an empty object to validate against, to force the error + // even if there are no translations at all + const translations = resource.relations?.translations || [{}]; + for (const translation of translations) { + try { + await validator.validate({ + schema: new PublishableTranslationValidator().schema, + data: translation, + }); + + // Stop validating if there is only one valid + // translation + break; + } catch (e) { + Object.assign(errors, e.messages); + } + } + } + + return Object.keys(errors).length ? errors : true; +} diff --git a/app/Models/Location.ts b/app/Models/Location.ts new file mode 100644 index 00000000..50677c3d --- /dev/null +++ b/app/Models/Location.ts @@ -0,0 +1,92 @@ +import { DateTime } from 'luxon'; +import { + BaseModel, + column, + manyToMany, + ManyToMany, + hasMany, + HasMany, + belongsTo, + BelongsTo, + beforeCreate, +} from '@ioc:Adonis/Lucid/Orm'; +import { cuid } from '@ioc:Adonis/Core/Helpers'; +import { PublishLocationValidator } from 'App/Validators/v1/LocationValidator'; +import { PublishLocationTranslationValidator } from 'App/Validators/v1/LocationTranslationValidator'; +import Address from 'App/Models/Address'; +import Link from 'App/Models/Link'; +import { publishable } from 'App/Helpers/Utilities'; + +export class LocationTranslation extends BaseModel { + @column({ isPrimary: true, serializeAs: null }) + public id: number; + + @column() + public language: string; + + @column() + public name: string; + + @column() + public description: string; + + @column({ serializeAs: null }) + public locationId: number; +} + +export enum LocationStatus { + DRAFT = 'draft', + PUBLISHED = 'published', +} + +export default class Location extends BaseModel { + @column({ isPrimary: true, serializeAs: null }) + public id: string; + + @column({ serializeAs: null }) + public publicId: string; + + @column() + public status: string; + + @column({ serializeAs: null }) + public addressId: number; + + @belongsTo(() => Address) + public address: BelongsTo<typeof Address>; + + @hasMany(() => LocationTranslation) + public translations: HasMany<typeof LocationTranslation>; + + @manyToMany(() => Link, { + relatedKey: 'id', + localKey: 'publicId', + pivotForeignKey: 'location_public_id', + pivotRelatedForeignKey: 'link_id', + pivotTable: 'location_links', + }) + public links: ManyToMany<typeof Link>; + + @column.dateTime({ autoCreate: true }) + public createdAt: DateTime; + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + public updatedAt: DateTime; + + public async publishable() { + return publishable( + this, + PublishLocationValidator, + PublishLocationTranslationValidator + ); + } + + @beforeCreate() + public static async setPublicId(location: Location) { + if (location.publicId) { + return; + } + + location.publicId = cuid(); + } +} diff --git a/app/Models/Organizer.ts b/app/Models/Organizer.ts index 153758a5..af5abbdb 100644 --- a/app/Models/Organizer.ts +++ b/app/Models/Organizer.ts @@ -14,11 +14,10 @@ import { cuid } from '@ioc:Adonis/Core/Helpers'; import Address from 'App/Models/Address'; import OrganizerType from 'App/Models/OrganizerType'; import OrganizerSubject from 'App/Models/OrganizerSubject'; -import { validator } from '@ioc:Adonis/Core/Validator'; import { PublishOrganizerValidator } from 'App/Validators/v1/OrganizerValidator'; import { PublishOrganizerTranslationValidator } from 'App/Validators/v1/OrganizerTranslationValidator'; -import Resource from 'App/Helpers/Api/Resource'; -import Link from './Link'; +import Link from 'App/Models/Link'; +import { publishable } from 'App/Helpers/Utilities'; export class OrganizerTranslation extends BaseModel { @column({ isPrimary: true, serializeAs: null }) @@ -53,37 +52,11 @@ export default class Organizer extends BaseModel { public status: string; public async publishable() { - const resource = new Resource(this).boot().toObject(); - - const errors = {}; - try { - await validator.validate({ - schema: new PublishOrganizerValidator(this).schema, - data: resource, - }); - } catch (e) { - Object.assign(errors, e.messages); - } - - // Use an empty object to validate against, to force the error - // even if there are no translations at all - const translations = resource.relations?.translations || [{}]; - for (const translation of translations) { - try { - await validator.validate({ - schema: new PublishOrganizerTranslationValidator().schema, - data: translation, - }); - - // Stop validating if there is only one valid - // translation - break; - } catch (e) { - Object.assign(errors, e.messages); - } - } - - return Object.keys(errors).length ? errors : true; + return publishable( + this, + PublishOrganizerValidator, + PublishOrganizerTranslationValidator + ); } @column({ serializeAs: null }) diff --git a/app/Validators/v1/LocationTranslationValidator.ts b/app/Validators/v1/LocationTranslationValidator.ts new file mode 100644 index 00000000..12afc623 --- /dev/null +++ b/app/Validators/v1/LocationTranslationValidator.ts @@ -0,0 +1,31 @@ +import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import { schema } from '@ioc:Adonis/Core/Validator'; +import { allowedLanguages } from 'Config/app'; + +export class LocationTranslationValidator { + constructor(private context: HttpContextContract) {} + + public schema = schema.create({ + attributes: schema.object().members({ + name: schema.string.optional({ trim: true }), + description: schema.string.optional({ trim: true }), + language: schema.enum(allowedLanguages), + }), + }); + + public cacheKey = this.context.routeKey; + + public messages = {}; +} + +export class PublishLocationTranslationValidator { + public schema = schema.create({ + attributes: schema.object().members({ + name: schema.string({ trim: true }), + description: schema.string({ trim: true }), + language: schema.enum(allowedLanguages), + }), + }); + + public messages = {}; +} diff --git a/app/Validators/v1/LocationValidator.ts b/app/Validators/v1/LocationValidator.ts new file mode 100644 index 00000000..0e42dbdd --- /dev/null +++ b/app/Validators/v1/LocationValidator.ts @@ -0,0 +1,86 @@ +import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import { schema, rules } from '@ioc:Adonis/Core/Validator'; +import { LocationStatus } from 'App/Models/Location'; + +export class CreateLocationValidator { + constructor(private context: HttpContextContract) {} + + public schema = schema.create({ + attributes: schema.object().members({ + name: schema.string({ trim: true }), + description: schema.string.optional({ trim: true }), + status: schema.enum.optional(Object.values(LocationStatus)), + }), + relations: schema.object.optional().members({ + address: schema.object.optional().members({ + attributes: schema.object().members({ + street1: schema.string({ trim: true }), + street2: schema.string.optional({ trim: true }), + zipCode: schema.string({ trim: true }), + city: schema.string({ trim: true }), + }), + }), + links: schema.array + .optional([rules.maxLength(3)]) + .members(schema.string({}, [rules.url()])), + }), + }); + + public cacheKey = this.context.routeKey; + + public messages = {}; +} + +export class UpdateLocationValidator { + constructor(private context: HttpContextContract) {} + + public schema = schema.create({ + attributes: schema.object.optional().members({ + status: schema.enum.optional(Object.values(LocationStatus)), + }), + relations: schema.object.optional().members({ + address: schema.object.optional().members({ + attributes: schema.object().members({ + street1: schema.string.optional({ trim: true }), + street2: schema.string.optional({ trim: true }), + zipCode: schema.string.optional({ trim: true }), + city: schema.string.optional({ trim: true }), + }), + }), + links: schema.array + .optional([rules.maxLength(3)]) + .members(schema.string({}, [rules.url()])), + }), + }); + + public cacheKey = this.context.routeKey; + + public messages = {}; +} + +export class PublishLocationValidator { + constructor(private context: HttpContextContract) {} + + public schema = schema.create({ + attributes: schema.object().members({ + status: schema.enum(Object.values(LocationStatus)), + }), + relations: schema.object().members({ + address: schema.object().members({ + attributes: schema.object().members({ + street1: schema.string({ trim: true }), + street2: schema.string.optional({ trim: true }), + zipCode: schema.string({ trim: true }), + city: schema.string({ trim: true }), + }), + }), + links: schema.array + .optional([rules.maxLength(3)]) + .members(schema.string({}, [rules.url()])), + }), + }); + + public cacheKey = this.context.routeKey; + + public messages = {}; +} diff --git a/database/migrations/1625644181250_locations.ts b/database/migrations/1625644181250_locations.ts new file mode 100644 index 00000000..ef069cf9 --- /dev/null +++ b/database/migrations/1625644181250_locations.ts @@ -0,0 +1,25 @@ +import BaseSchema from '@ioc:Adonis/Lucid/Schema'; +import { LocationStatus } from 'App/Models/Location'; + +export default class Locations extends BaseSchema { + protected tableName = 'locations'; + + public async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id').primary(); + table.string('public_id').notNullable().unique(); + + table + .enu('status', [LocationStatus.DRAFT, LocationStatus.PUBLISHED]) + .defaultTo(LocationStatus.DRAFT); + + table.integer('address_id').unsigned().references('addresses.id'); + + table.timestamps(true); + }); + } + + public async down() { + this.schema.dropTable(this.tableName); + } +} diff --git a/database/migrations/1625644181255_location_translations.ts b/database/migrations/1625644181255_location_translations.ts new file mode 100644 index 00000000..061a42eb --- /dev/null +++ b/database/migrations/1625644181255_location_translations.ts @@ -0,0 +1,25 @@ +import BaseSchema from '@ioc:Adonis/Lucid/Schema'; +import { Languages } from 'App/Helpers/Languages'; + +export default class LocationTranslations extends BaseSchema { + protected tableName = 'location_translations'; + + public async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id').primary(); + + table + .enu('language', [Languages.DE, Languages.EN]) + .defaultTo(Languages.DE); + + table.integer('location_id').unsigned().references('locations.id'); + + table.string('name').notNullable(); + table.text('description'); + }); + } + + public async down() { + this.schema.dropTable(this.tableName); + } +} diff --git a/database/migrations/1625644181260_location_links.ts b/database/migrations/1625644181260_location_links.ts new file mode 100644 index 00000000..d67429c8 --- /dev/null +++ b/database/migrations/1625644181260_location_links.ts @@ -0,0 +1,27 @@ +import BaseSchema from '@ioc:Adonis/Lucid/Schema'; + +export default class LocationLinks extends BaseSchema { + protected tableName = 'location_links'; + + public async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id'); + table.timestamps(true); + + table + .string('location_public_id') + .unsigned() + .references('locations.public_id') + .onDelete('CASCADE'); + table + .integer('link_id') + .unsigned() + .references('links.id') + .onDelete('CASCADE'); + }); + } + + public async down() { + this.schema.dropTable(this.tableName); + } +} diff --git a/start/routes.ts b/start/routes.ts index d5eaec4d..8cd7f53b 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -56,5 +56,8 @@ Route.group(() => { 'organizerType.organizerSubject', 'v1/OrganizerSubjectController' ).apiOnly(); + + Route.resource('location', 'v1/LocationController').apiOnly(); + Route.post('location/:id/translate', 'v1/LocationController.translate'); }); }).prefix('v1');