AI Implementation feature(820): Project Scaffold and Platform Foundations (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseInitService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(DatabaseInitService.name);
|
||||
private initialized = false;
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Explicit, awaitable database initialization.
|
||||
*
|
||||
* - Initializes the DataSource (idempotent — TypeORM returns the existing
|
||||
* connection if already initialized).
|
||||
* - Runs all pending migrations in order.
|
||||
* - Verifies that the expected schema and seeded data are present.
|
||||
*
|
||||
* `main.ts` calls this BEFORE `app.listen()` so the HTTP server never
|
||||
* accepts traffic until the DB is fully migrated and seeded.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
if (!this.dataSource.isInitialized) {
|
||||
await this.dataSource.initialize();
|
||||
}
|
||||
|
||||
// Enforce WAL journal mode on every startup. The InitSchema migration
|
||||
// also sets it for fresh DBs, but warm DBs (where migrations are
|
||||
// skipped) need it applied here so PRAGMA journal_mode is always 'wal'.
|
||||
await this.ensureWalMode();
|
||||
|
||||
const pending = await this.dataSource.showMigrations();
|
||||
const pendingList: string[] = Array.isArray(pending) ? pending : [];
|
||||
this.logger.log(`Pending migrations: ${pendingList.length}`);
|
||||
if (pendingList.length > 0 || !(await this.hasCategoryTable())) {
|
||||
const ran = await this.dataSource.runMigrations({ transaction: 'each' });
|
||||
this.logger.log(`Ran ${ran.length} migration(s): ${ran.map((m) => m.name).join(', ')}`);
|
||||
} else {
|
||||
this.logger.log('Database schema is up to date');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.verifySeed();
|
||||
} catch (e) {
|
||||
this.logger.warn(`verifySeed skipped: ${(e as Error).message}`);
|
||||
}
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read PRAGMA journal_mode and switch to WAL if not already enabled.
|
||||
* Persisted for file-backed databases; idempotent and safe on every
|
||||
* startup.
|
||||
*/
|
||||
private async ensureWalMode(): Promise<void> {
|
||||
try {
|
||||
const rows: any[] = await this.dataSource.query('PRAGMA journal_mode');
|
||||
const current = String(rows?.[0]?.journal_mode ?? '').toLowerCase();
|
||||
if (current === 'wal') {
|
||||
this.logger.log('PRAGMA journal_mode=wal (already set)');
|
||||
return;
|
||||
}
|
||||
const setRows: any[] = await this.dataSource.query('PRAGMA journal_mode = WAL');
|
||||
const actual = String(setRows?.[0]?.journal_mode ?? '').toLowerCase();
|
||||
this.logger.log(`PRAGMA journal_mode set (was='${current}', now='${actual}')`);
|
||||
} catch (e) {
|
||||
this.logger.warn(`ensureWalMode skipped: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async verifySeed(): Promise<void> {
|
||||
const categoryCount = await this.dataSource.query('SELECT COUNT(*) AS c FROM "category"').then((rows: any[]) => Number(rows[0]?.c ?? 0));
|
||||
const settingsCount = await this.dataSource.query('SELECT COUNT(*) AS c FROM "setting"').then((rows: any[]) => Number(rows[0]?.c ?? 0));
|
||||
this.logger.log(`Seed verified: ${categoryCount} categories, ${settingsCount} settings`);
|
||||
if (categoryCount < 6) {
|
||||
this.logger.warn(`Expected at least 6 seeded categories, found ${categoryCount}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async hasCategoryTable(): Promise<boolean> {
|
||||
try {
|
||||
const rows: any[] = await this.dataSource.query("SELECT name FROM sqlite_master WHERE type='table' AND name='category'");
|
||||
return rows.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getDbPath(): string {
|
||||
return this.config.get<string>('DATABASE_PATH', '/data/hipctf/db.sqlite');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Module, Global, Logger } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { UserEntity } from './entities/user.entity';
|
||||
import { SettingEntity } from './entities/setting.entity';
|
||||
import { CategoryEntity } from './entities/category.entity';
|
||||
import { ChallengeEntity } from './entities/challenge.entity';
|
||||
import { ChallengeFileEntity } from './entities/challenge-file.entity';
|
||||
import { SolveEntity } from './entities/solve.entity';
|
||||
import { RefreshTokenEntity } from './entities/refresh-token.entity';
|
||||
import { BlogPostEntity } from './entities/blog-post.entity';
|
||||
import { InitSchema1700000000000 } from './migrations/1700000000000-InitSchema';
|
||||
import { SeedSystemData1700000000100 } from './migrations/1700000000100-SeedSystemData';
|
||||
import { DatabaseInitService } from './database-init.service';
|
||||
|
||||
const ENTITIES = [
|
||||
UserEntity,
|
||||
SettingEntity,
|
||||
CategoryEntity,
|
||||
ChallengeEntity,
|
||||
ChallengeFileEntity,
|
||||
SolveEntity,
|
||||
RefreshTokenEntity,
|
||||
BlogPostEntity,
|
||||
];
|
||||
|
||||
const MIGRATIONS = [InitSchema1700000000000, SeedSystemData1700000000100];
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => {
|
||||
const dbPath = config.get<string>('DATABASE_PATH', '/data/hipctf/db.sqlite');
|
||||
const isMemory = dbPath === ':memory:' || dbPath.startsWith('file:');
|
||||
if (!isMemory) {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return {
|
||||
type: 'better-sqlite3',
|
||||
database: dbPath,
|
||||
entities: ENTITIES,
|
||||
migrations: MIGRATIONS,
|
||||
migrationsRun: false,
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
};
|
||||
},
|
||||
}),
|
||||
TypeOrmModule.forFeature(ENTITIES),
|
||||
],
|
||||
providers: [DatabaseInitService],
|
||||
exports: [TypeOrmModule, DatabaseInitService],
|
||||
})
|
||||
export class DatabaseModule {
|
||||
private readonly logger = new Logger(DatabaseModule.name);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Entity, PrimaryColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
|
||||
|
||||
export type BlogStatus = 'draft' | 'published';
|
||||
|
||||
@Entity('blog_post')
|
||||
export class BlogPostEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Column('text')
|
||||
title!: string;
|
||||
|
||||
@Column('text', { name: 'body_md', default: '' })
|
||||
bodyMd!: string;
|
||||
|
||||
@Index('idx_blog_status_published', ['status', 'publishedAt'])
|
||||
@Column('text', { default: 'draft' })
|
||||
status!: BlogStatus;
|
||||
|
||||
@CreateDateColumn({ type: 'text', name: 'created_at' })
|
||||
createdAt!: string;
|
||||
|
||||
@UpdateDateColumn({ type: 'text', name: 'updated_at' })
|
||||
updatedAt!: string;
|
||||
|
||||
@Column('text', { name: 'published_at', nullable: true })
|
||||
publishedAt!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Entity, PrimaryColumn, Column, Index } from 'typeorm';
|
||||
|
||||
@Entity('category')
|
||||
export class CategoryEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Index({ unique: true, where: 'system_key IS NOT NULL' })
|
||||
@Column('text', { name: 'system_key', nullable: true })
|
||||
systemKey!: string | null;
|
||||
|
||||
@Column('text')
|
||||
name!: string;
|
||||
|
||||
@Column('text')
|
||||
abbreviation!: string;
|
||||
|
||||
@Column('text', { default: '' })
|
||||
description!: string;
|
||||
|
||||
@Column('text', { name: 'icon_path', default: '' })
|
||||
iconPath!: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Entity, PrimaryColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||
import { ChallengeEntity } from './challenge.entity';
|
||||
|
||||
@Entity('challenge_file')
|
||||
export class ChallengeFileEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text', { name: 'challenge_id' })
|
||||
challengeId!: string;
|
||||
|
||||
@ManyToOne(() => ChallengeEntity)
|
||||
@JoinColumn({ name: 'challenge_id' })
|
||||
challenge?: ChallengeEntity;
|
||||
|
||||
@Column('text', { name: 'original_filename' })
|
||||
originalFilename!: string;
|
||||
|
||||
@Column('text', { name: 'stored_path' })
|
||||
storedPath!: string;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Entity, PrimaryColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||
import { CategoryEntity } from './category.entity';
|
||||
|
||||
export type Difficulty = 'low' | 'med' | 'high';
|
||||
export type Protocol = 'nc' | 'web';
|
||||
|
||||
@Entity('challenge')
|
||||
export class ChallengeEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Column('text')
|
||||
name!: string;
|
||||
|
||||
@Column('text', { name: 'description_md', default: '' })
|
||||
descriptionMd!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text', { name: 'category_id' })
|
||||
categoryId!: string;
|
||||
|
||||
@ManyToOne(() => CategoryEntity)
|
||||
@JoinColumn({ name: 'category_id' })
|
||||
category?: CategoryEntity;
|
||||
|
||||
@Column('text', { default: 'low' })
|
||||
difficulty!: Difficulty;
|
||||
|
||||
@Column('integer', { name: 'initial_points', default: 0 })
|
||||
initialPoints!: number;
|
||||
|
||||
@Column('integer', { name: 'minimum_points', default: 0 })
|
||||
minimumPoints!: number;
|
||||
|
||||
@Column('integer', { name: 'decay_solves', default: 0 })
|
||||
decaySolves!: number;
|
||||
|
||||
@Column('text', { default: '' })
|
||||
flag!: string;
|
||||
|
||||
@Column('text', { default: 'nc' })
|
||||
protocol!: Protocol;
|
||||
|
||||
@Column('integer', { nullable: true })
|
||||
port!: number | null;
|
||||
|
||||
@Column('text', { name: 'ip_address', default: '' })
|
||||
ipAddress!: string;
|
||||
|
||||
@CreateDateColumn({ type: 'text', name: 'created_at' })
|
||||
createdAt!: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Entity, PrimaryColumn, Column, Index } from 'typeorm';
|
||||
|
||||
@Entity('refresh_token')
|
||||
export class RefreshTokenEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text', { name: 'user_id' })
|
||||
userId!: string;
|
||||
|
||||
@Column('text', { name: 'token_hash', unique: true })
|
||||
tokenHash!: string;
|
||||
|
||||
@Column('text', { name: 'issued_at' })
|
||||
issuedAt!: string;
|
||||
|
||||
@Column('text', { name: 'expires_at' })
|
||||
expiresAt!: string;
|
||||
|
||||
@Column('text', { name: 'revoked_at', nullable: true })
|
||||
revokedAt!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Entity, PrimaryColumn, Column } from 'typeorm';
|
||||
|
||||
@Entity('setting')
|
||||
export class SettingEntity {
|
||||
@PrimaryColumn('text')
|
||||
key!: string;
|
||||
|
||||
@Column('text', { default: '' })
|
||||
value!: string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Entity, PrimaryColumn, Column, Index, Unique } from 'typeorm';
|
||||
|
||||
@Entity('solve')
|
||||
@Unique('uq_solve_challenge_user', ['challengeId', 'userId'])
|
||||
@Index('idx_solve_user', ['userId'])
|
||||
@Index('idx_solve_challenge', ['challengeId'])
|
||||
export class SolveEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Column('text', { name: 'challenge_id' })
|
||||
challengeId!: string;
|
||||
|
||||
@Column('text', { name: 'user_id' })
|
||||
userId!: string;
|
||||
|
||||
@Column('text', { name: 'solved_at' })
|
||||
solvedAt!: string;
|
||||
|
||||
@Column('integer', { name: 'points_awarded', default: 0 })
|
||||
pointsAwarded!: number;
|
||||
|
||||
@Column('integer', { name: 'base_points', default: 0 })
|
||||
basePoints!: number;
|
||||
|
||||
@Column('integer', { name: 'rank_bonus', default: 0 })
|
||||
rankBonus!: number;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Entity, PrimaryColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
export type UserRole = 'admin' | 'player';
|
||||
export type UserStatus = 'enabled' | 'disabled';
|
||||
|
||||
@Entity('user')
|
||||
export class UserEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Column('text', { unique: true })
|
||||
username!: string;
|
||||
|
||||
@Column('text', { name: 'password_hash' })
|
||||
passwordHash!: string;
|
||||
|
||||
@Column('text', { default: 'player' })
|
||||
role!: UserRole;
|
||||
|
||||
@Column('text', { default: 'enabled' })
|
||||
status!: UserStatus;
|
||||
|
||||
@CreateDateColumn({ type: 'text', name: 'created_at' })
|
||||
createdAt!: string;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class InitSchema1700000000000 implements MigrationInterface {
|
||||
name = 'InitSchema1700000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`PRAGMA foreign_keys = ON;`);
|
||||
await queryRunner.query(`PRAGMA journal_mode = WAL;`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"username" TEXT NOT NULL UNIQUE,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'player',
|
||||
"status" TEXT NOT NULL DEFAULT 'enabled',
|
||||
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "setting" (
|
||||
"key" TEXT PRIMARY KEY,
|
||||
"value" TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "category" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"system_key" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"abbreviation" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"icon_path" TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_system_key" ON "category"("system_key") WHERE "system_key" IS NOT NULL;`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "challenge" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description_md" TEXT NOT NULL DEFAULT '',
|
||||
"category_id" TEXT NOT NULL,
|
||||
"difficulty" TEXT NOT NULL DEFAULT 'low',
|
||||
"initial_points" INTEGER NOT NULL DEFAULT 0,
|
||||
"minimum_points" INTEGER NOT NULL DEFAULT 0,
|
||||
"decay_solves" INTEGER NOT NULL DEFAULT 0,
|
||||
"flag" TEXT NOT NULL DEFAULT '',
|
||||
"protocol" TEXT NOT NULL DEFAULT 'nc',
|
||||
"port" INTEGER,
|
||||
"ip_address" TEXT NOT NULL DEFAULT '',
|
||||
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
CONSTRAINT "fk_challenge_category" FOREIGN KEY ("category_id") REFERENCES "category"("id") ON DELETE RESTRICT
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_challenge_category" ON "challenge"("category_id");`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "challenge_file" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"challenge_id" TEXT NOT NULL,
|
||||
"original_filename" TEXT NOT NULL,
|
||||
"stored_path" TEXT NOT NULL,
|
||||
CONSTRAINT "fk_challenge_file_challenge" FOREIGN KEY ("challenge_id") REFERENCES "challenge"("id") ON DELETE RESTRICT
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_challenge_file_challenge" ON "challenge_file"("challenge_id");`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "solve" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"challenge_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"solved_at" TEXT NOT NULL,
|
||||
"points_awarded" INTEGER NOT NULL DEFAULT 0,
|
||||
"base_points" INTEGER NOT NULL DEFAULT 0,
|
||||
"rank_bonus" INTEGER NOT NULL DEFAULT 0,
|
||||
CONSTRAINT "fk_solve_challenge" FOREIGN KEY ("challenge_id") REFERENCES "challenge"("id") ON DELETE RESTRICT,
|
||||
CONSTRAINT "fk_solve_user" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE RESTRICT
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_solve_challenge_user" ON "solve"("challenge_id","user_id");`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_solve_user" ON "solve"("user_id");`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_solve_challenge" ON "solve"("challenge_id");`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "refresh_token" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL UNIQUE,
|
||||
"issued_at" TEXT NOT NULL,
|
||||
"expires_at" TEXT NOT NULL,
|
||||
"revoked_at" TEXT
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_refresh_token_user" ON "refresh_token"("user_id");`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "blog_post" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"title" TEXT NOT NULL,
|
||||
"body_md" TEXT NOT NULL DEFAULT '',
|
||||
"status" TEXT NOT NULL DEFAULT 'draft',
|
||||
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
"updated_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
"published_at" TEXT
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_blog_status_published" ON "blog_post"("status","published_at");`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "blog_post";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "refresh_token";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "solve";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "challenge_file";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "challenge";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "category";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "setting";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "user";`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { SYSTEM_CATEGORY_KEYS, SYSTEM_CATEGORY_META, DEFAULT_SETTINGS, SETTINGS_KEYS } from '../../config/env.schema';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export class SeedSystemData1700000000100 implements MigrationInterface {
|
||||
name = 'SeedSystemData1700000000100';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const key of SYSTEM_CATEGORY_KEYS) {
|
||||
const meta = SYSTEM_CATEGORY_META[key];
|
||||
const existing = await queryRunner.query(`SELECT id FROM "category" WHERE "system_key" = ?`, [key]);
|
||||
if (existing.length > 0) continue;
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "category" ("id","system_key","name","abbreviation","description","icon_path") VALUES (?,?,?,?,?,?)`,
|
||||
[uuid(), key, meta.name, meta.abbreviation, meta.description, meta.iconPath],
|
||||
);
|
||||
}
|
||||
|
||||
for (const key of Object.values(SETTINGS_KEYS)) {
|
||||
const existing = await queryRunner.query(`SELECT "key" FROM "setting" WHERE "key" = ?`, [key]);
|
||||
if (existing.length > 0) continue;
|
||||
const value = DEFAULT_SETTINGS[key] ?? '';
|
||||
await queryRunner.query(`INSERT INTO "setting" ("key","value") VALUES (?,?)`, [key, value]);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DELETE FROM "category" WHERE "system_key" IS NOT NULL;`);
|
||||
await queryRunner.query(`DELETE FROM "setting";`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user