import Sequelize, { CreationOptional, ForeignKey, InferAttributes, InferCreationAttributes, Model } from "sequelize"; import { Field, ID, PadId, Type, TypeCreate, TypeUpdate } from "facilmap-types"; import Database from "./database"; import { createModel, getDefaultIdType, makeNotNullForeignKey, validateColour } from "./helpers"; import { PadModel } from "./pad"; export interface TypeModel extends Model, InferCreationAttributes> { id: CreationOptional; name: string; type: "marker" | "line"; padId: ForeignKey; defaultColour: string | null; colourFixed: boolean | null; defaultSize: string | null; sizeFixed: boolean | null; defaultSymbol: string | null; symbolFixed: boolean | null; defaultShape: string | null; shapeFixed: boolean | null; defaultWidth: string | null; widthFixed: boolean | null; defaultMode: string | null; modeFixed: boolean | null; showInLegend: boolean | null; fields: Field[]; toJSON: () => Type; }; const DEFAULT_TYPES: TypeCreate[] = [ { name: "Marker", type: "marker", fields: [ { name: "Description", type: "textarea" } ] }, { name: "Line", type: "line", fields: [ { name: "Description", type: "textarea" } ] } ]; export default class DatabaseTypes { TypeModel = createModel(); _db: Database; constructor(database: Database) { this._db = database; this.TypeModel.init({ id: getDefaultIdType(), name: { type: Sequelize.TEXT, allowNull: false }, type: { type: Sequelize.ENUM("marker", "line"), allowNull: false }, defaultColour: { type: Sequelize.STRING(6), allowNull: true, validate: validateColour }, colourFixed: { type: Sequelize.BOOLEAN, allowNull: true }, defaultSize: { type: Sequelize.INTEGER.UNSIGNED, allowNull: true, validate: { min: 15 } }, sizeFixed: { type: Sequelize.BOOLEAN, allowNull: true }, defaultSymbol: { type: Sequelize.TEXT, allowNull: true}, symbolFixed: { type: Sequelize.BOOLEAN, allowNull: true}, defaultShape: { type: Sequelize.TEXT, allowNull: true }, shapeFixed: { type: Sequelize.BOOLEAN, allowNull: true }, defaultWidth: { type: Sequelize.INTEGER.UNSIGNED, allowNull: true, validate: { min: 1 } }, widthFixed: { type: Sequelize.BOOLEAN, allowNull: true }, defaultMode: { type: Sequelize.TEXT, allowNull: true }, modeFixed: { type: Sequelize.BOOLEAN, allowNull: true }, showInLegend: { type: Sequelize.BOOLEAN, allowNull: true }, fields: { type: Sequelize.TEXT, allowNull: false, get: function(this: TypeModel) { const fields = this.getDataValue("fields") as any as string; return fields == null ? [] : JSON.parse(fields); }, set: function(this: TypeModel, v: Field[]) { for(const field of v) { if(field.controlSymbol) { for(const option of field.options ?? []) { if(!option.symbol) option.symbol = ""; // Avoid "undefined" ending up there, which messes everything up } } if(field.controlShape) { for(const option of field.options ?? []) { if(!option.shape) option.shape = ""; // Avoid "undefined" ending up there, which messes everything up } } } return this.setDataValue("fields", JSON.stringify(v) as any); }, validate: { checkUniqueFieldName: (value: string) => { const fields = JSON.parse(value) as Field[]; const fieldNames = new Set(); for (const field of fields) { if(field.name.trim().length == 0) throw new Error("Empty field name."); if(fieldNames.has(field.name)) throw new Error("field name "+field.name+" is not unique."); fieldNames.add(field.name); if([ "textarea", "dropdown", "checkbox", "input" ].indexOf(field.type) == -1) throw new Error("Invalid field type "+field.type+" for field "+field.name+"."); if(field.controlColour) { if(!field.options || field.options.length < 1) throw new Error("No options specified for colour-controlling field "+field.name+"."); for (const option of field.options) { if(!option.colour || !option.colour.match(validateColour.is)) throw new Error("Invalid colour "+option.colour+" in field "+field.name+"."); } } if(field.controlSize) { if(!field.options || field.options.length < 1) throw new Error("No options specified for size-controlling field "+field.name+"."); for(const option of field.options) { if(!option.size || !isFinite(option.size) || option.size < 15) throw new Error("Invalid size "+option.size+" in field "+field.name+"."); } } if(field.controlSymbol) { if(!field.options || field.options.length < 1) throw new Error("No options specified for icon-controlling field "+field.name+"."); } if(field.controlWidth) { if(!field.options || field.options.length < 1) throw new Error("No options specified for width-controlling field "+field.name+"."); for(const option of field.options) { if(!option.width || !(1*option.width >= 1)) throw new Error("Invalid width "+option.width+" in field "+field.name+"."); } } // Validate unique dropdown entries if(field.type == "dropdown") { const existingValues = new Set(); for(const option of (field.options || [])) { if(existingValues.has(option.value)) throw new Error(`Duplicate option "${option.value}" for field "${field.name}".`); existingValues.add(option.value); } } } } } } }, { sequelize: this._db._conn, validate: { defaultValsNotNull: function() { if(this.colourFixed && this.defaultColour == null) throw new Error("Fixed colour cannot be undefined."); if(this.sizeFixed && this.defaultSize == null) throw new Error("Fixed size cannot be undefined."); if(this.widthFixed && this.defaultWidth == null) throw new Error("Fixed width cannot be undefined."); } }, modelName: "Type" }); } afterInit(): void { const PadModel = this._db.pads.PadModel; this.TypeModel.belongsTo(PadModel, makeNotNullForeignKey("pad", "padId")); PadModel.hasMany(this.TypeModel, { foreignKey: "padId" }); } getTypes(padId: PadId): Highland.Stream { return this._db.helpers._getPadObjects("Type", padId); } getType(padId: PadId, typeId: ID): Promise { return this._db.helpers._getPadObject("Type", padId, typeId); } async createType(padId: PadId, data: TypeCreate): Promise { if(data.name == null || data.name.trim().length == 0) throw new Error("No name provided."); const createdType = await this._db.helpers._createPadObject("Type", padId, data); this._db.emit("type", createdType.padId, createdType); return createdType; } async updateType(padId: PadId, typeId: ID, data: TypeUpdate, _doNotUpdateStyles?: boolean): Promise { if(data.name == null || data.name.trim().length == 0) throw new Error("No name provided."); const result = await this._db.helpers._updatePadObject("Type", padId, typeId, data); this._db.emit("type", result.padId, result); if(!_doNotUpdateStyles) await this.recalculateObjectStylesForType(result.padId, typeId, result.type == "line"); return result; } async recalculateObjectStylesForType(padId: PadId, typeId: ID, isLine: boolean): Promise { await this._db.helpers._updateObjectStyles(isLine ? this._db.lines.getPadLinesByType(padId, typeId) : this._db.markers.getPadMarkersByType(padId, typeId)); } async isTypeUsed(padId: PadId, typeId: ID): Promise { const [ marker, line ] = await Promise.all([ this._db.markers.MarkerModel.findOne({ where: { padId: padId, typeId: typeId } }), this._db.lines.LineModel.findOne({ where: { padId: padId, typeId: typeId } }) ]); return !!marker || !!line; } async deleteType(padId: PadId, typeId: ID): Promise { if (await this.isTypeUsed(padId, typeId)) throw new Error("This type is in use."); const type = await this._db.helpers._deletePadObject("Type", padId, typeId); this._db.emit("deleteType", padId, { id: type.id }); return type; } async createDefaultTypes(padId: PadId): Promise { return await Promise.all(DEFAULT_TYPES.map((it) => this.createType(padId, it))); } }