feat: add attendance device SN classroom bindings
This commit is contained in:
@@ -11,6 +11,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.ensureAiConfigTable();
|
||||
await this.ensureCourseAttendanceSchema();
|
||||
await this.ensureAttendanceDevicesSchema();
|
||||
await this.ensureStudentWalletSchema();
|
||||
await this.backfillOrganizations();
|
||||
await this.normalizeClassDates();
|
||||
@@ -22,6 +23,64 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
await this.normalizeClassroomStatuses();
|
||||
}
|
||||
|
||||
private async ensureAttendanceDevicesSchema(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const isMySQL = this.dataSource.options.type === 'mysql';
|
||||
const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT';
|
||||
await runner.query(`CREATE TABLE IF NOT EXISTS attendance_devices (
|
||||
id ${pk},
|
||||
device_sn VARCHAR(100) NOT NULL,
|
||||
device_name VARCHAR(100) NOT NULL,
|
||||
classroom_id INTEGER NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
location VARCHAR(200),
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
const table = await runner.getTable('attendance_devices');
|
||||
const columnNames = new Set(table?.columns.map((column) => column.name) ?? []);
|
||||
const additions: Array<[string, string]> = [
|
||||
['device_sn', 'VARCHAR(100) NOT NULL DEFAULT \'\''],
|
||||
['device_name', 'VARCHAR(100) NOT NULL DEFAULT \'\''],
|
||||
['classroom_id', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['status', "VARCHAR(20) NOT NULL DEFAULT 'active'"],
|
||||
['location', 'VARCHAR(200)'],
|
||||
['notes', 'TEXT'],
|
||||
['created_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'],
|
||||
['updated_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'],
|
||||
];
|
||||
for (const [name, definition] of additions) {
|
||||
if (!columnNames.has(name)) await runner.query(`ALTER TABLE attendance_devices ADD COLUMN ${name} ${definition}`);
|
||||
}
|
||||
const refreshed = await runner.getTable('attendance_devices');
|
||||
const createIndex = async (sql: string) => {
|
||||
try {
|
||||
await runner.query(sql);
|
||||
} catch {
|
||||
// Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent.
|
||||
}
|
||||
};
|
||||
const uniqueSn = refreshed?.indices.some((index) => index.columnNames.length === 1 && index.columnNames[0] === 'device_sn' && index.isUnique);
|
||||
if (!uniqueSn) {
|
||||
await createIndex(
|
||||
isMySQL
|
||||
? 'CREATE UNIQUE INDEX idx_attendance_devices_device_sn ON attendance_devices (device_sn)'
|
||||
: 'CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_devices_device_sn ON attendance_devices (device_sn)',
|
||||
);
|
||||
}
|
||||
await createIndex(
|
||||
isMySQL
|
||||
? 'CREATE INDEX idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)'
|
||||
: 'CREATE INDEX IF NOT EXISTS idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)',
|
||||
);
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureStudentWalletSchema(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
@@ -456,25 +515,57 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
|
||||
private async normalizeClassDates(): Promise<void> {
|
||||
const driver = this.dataSource.options.type;
|
||||
const dateExpression = (column: string) =>
|
||||
driver === 'mysql' ? `DATE(${column})` : `substr(${column}, 1, 10)`;
|
||||
let columns: Array<'start_date' | 'end_date'> = ['start_date', 'end_date'];
|
||||
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const table = await runner.getTable('classes');
|
||||
if (!table) return;
|
||||
|
||||
// Fresh MySQL schemas created by TypeORM already use native DATE columns.
|
||||
// This cleanup is only for legacy schemas that stored dates as strings;
|
||||
// comparing a native DATE column with '' raises ER_TRUNCATED_WRONG_VALUE
|
||||
// in strict SQL mode.
|
||||
if (driver === 'mysql') {
|
||||
columns = columns.filter((columnName) => {
|
||||
const column = table.columns.find((item) => item.name === columnName);
|
||||
const type = String(column?.type ?? '').toLowerCase();
|
||||
return !['date', 'datetime', 'timestamp'].includes(type);
|
||||
});
|
||||
if (columns.length === 0) return;
|
||||
}
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
|
||||
const columnText = (column: string) =>
|
||||
driver === 'mysql' ? `CAST(${column} AS CHAR)` : column;
|
||||
const firstTenChars = (column: string) =>
|
||||
driver === 'mysql'
|
||||
? `NULLIF(LEFT(${columnText(column)}, 10), '')`
|
||||
: `NULLIF(substr(${column}, 1, 10), '')`;
|
||||
const normalizedDate = (column: string) => `CASE
|
||||
WHEN ${column} IS NULL THEN NULL
|
||||
ELSE ${firstTenChars(column)}
|
||||
END`;
|
||||
const lengthFunction = driver === 'mysql' ? 'CHAR_LENGTH' : 'length';
|
||||
const needsNormalization = (column: string) => `(
|
||||
${column} IS NOT NULL
|
||||
AND (${columnText(column)} = '' OR ${lengthFunction}(${columnText(column)}) > 10)
|
||||
)`;
|
||||
|
||||
const assignments = columns
|
||||
.map((column) => `${column} = ${normalizedDate(column)}`)
|
||||
.join(',\n ');
|
||||
const predicates = columns.map((column) => needsNormalization(column)).join('\n OR ');
|
||||
const result = await this.dataSource.transaction((manager) =>
|
||||
manager.query(`
|
||||
UPDATE classes
|
||||
SET
|
||||
start_date = CASE
|
||||
WHEN start_date IS NULL OR start_date = '' THEN start_date
|
||||
ELSE ${dateExpression('start_date')}
|
||||
END,
|
||||
end_date = CASE
|
||||
WHEN end_date IS NULL OR end_date = '' THEN end_date
|
||||
ELSE ${dateExpression('end_date')}
|
||||
END
|
||||
${assignments}
|
||||
WHERE
|
||||
(start_date IS NOT NULL AND ${lengthFunction}(start_date) > 10)
|
||||
OR (end_date IS NOT NULL AND ${lengthFunction}(end_date) > 10)
|
||||
${predicates}
|
||||
`),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user