Files
access-manage/src/modules/shifts/shift.repository.ts
T
2026-06-02 12:23:00 +08:00

255 lines
6.9 KiB
TypeScript

import type { ResultSetHeader, RowDataPacket } from "mysql2/promise";
import { pool } from "../../db/pool";
import type { AuthUser } from "../auth/auth.types";
import type { CreateShiftInput, ListShiftsQuery, Shift, UpdateShiftInput } from "./shift.types";
type SqlParam = string | number | Date | null;
interface ShiftRow extends RowDataPacket {
id: number;
store_id: number;
store_name: string;
employee_id: number;
employee_name: string;
role_name: string | null;
start_at: Date;
end_at: Date;
status: Shift["status"];
created_at: Date;
updated_at: Date;
}
interface CountRow extends RowDataPacket {
total: number;
}
function toIso(value: Date): string {
return value.toISOString();
}
function toShift(row: ShiftRow): Shift {
return {
id: row.id,
storeId: row.store_id,
storeName: row.store_name,
employeeId: row.employee_id,
employeeName: row.employee_name,
roleName: row.role_name,
startAt: toIso(row.start_at),
endAt: toIso(row.end_at),
status: row.status,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function actorValues(user: AuthUser): [number | null, number | null] {
return user.accountType === "SUPER_ADMIN" ? [user.id, null] : [null, user.id];
}
function buildWhere(query: ListShiftsQuery): { whereSql: string; params: SqlParam[] } {
const where = ["sh.deleted_at IS NULL"];
const params: SqlParam[] = [];
if (query.storeId !== undefined) {
where.push("sh.store_id = ?");
params.push(query.storeId);
}
if (query.employeeId !== undefined) {
where.push("sh.employee_id = ?");
params.push(query.employeeId);
}
if (query.status) {
where.push("sh.status = ?");
params.push(query.status);
}
if (query.startDate) {
where.push("sh.start_at >= ?");
params.push(`${query.startDate} 00:00:00.000`);
}
if (query.endDate) {
where.push("sh.start_at < DATE_ADD(?, INTERVAL 1 DAY)");
params.push(`${query.endDate} 00:00:00.000`);
}
return { whereSql: where.join(" AND "), params };
}
export const shiftRepository = {
async employeeBelongsToStore(employeeId: number, storeId: number): Promise<boolean> {
const [rows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM employees
WHERE id = ?
AND store_id = ?
AND status = 'ACTIVE'
AND deleted_at IS NULL
`,
[employeeId, storeId],
);
return (rows[0]?.total ?? 0) > 0;
},
async hasOverlappingShift(
employeeId: number,
startAt: string,
endAt: string,
excludeShiftId?: number,
): Promise<boolean> {
const params: SqlParam[] = [employeeId, new Date(endAt), new Date(startAt)];
const excludeSql = excludeShiftId === undefined ? "" : "AND id <> ?";
if (excludeShiftId !== undefined) {
params.push(excludeShiftId);
}
const [rows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM shifts
WHERE employee_id = ?
AND deleted_at IS NULL
AND status <> 'CANCELLED'
AND start_at < ?
AND end_at > ?
${excludeSql}
`,
params,
);
return (rows[0]?.total ?? 0) > 0;
},
async list(query: ListShiftsQuery): Promise<{ items: Shift[]; total: number }> {
const { whereSql, params } = buildWhere(query);
const offset = (query.page - 1) * query.pageSize;
const [countRows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM shifts sh
INNER JOIN stores s ON s.id = sh.store_id
INNER JOIN employees e ON e.id = sh.employee_id
WHERE ${whereSql}
`,
params,
);
const [rows] = await pool.execute<ShiftRow[]>(
`
SELECT sh.*, s.name AS store_name, e.name AS employee_name
FROM shifts sh
INNER JOIN stores s ON s.id = sh.store_id
INNER JOIN employees e ON e.id = sh.employee_id
WHERE ${whereSql}
ORDER BY sh.start_at ASC, sh.id ASC
LIMIT ${query.pageSize} OFFSET ${offset}
`,
params,
);
return {
items: rows.map(toShift),
total: countRows[0]?.total ?? 0,
};
},
async findById(id: number): Promise<Shift | null> {
const [rows] = await pool.execute<ShiftRow[]>(
`
SELECT sh.*, s.name AS store_name, e.name AS employee_name
FROM shifts sh
INNER JOIN stores s ON s.id = sh.store_id
INNER JOIN employees e ON e.id = sh.employee_id
WHERE sh.id = ? AND sh.deleted_at IS NULL
LIMIT 1
`,
[id],
);
return rows[0] ? toShift(rows[0]) : null;
},
async create(input: CreateShiftInput, user: AuthUser): Promise<number> {
const [creatorAdminId, creatorEmployeeId] = actorValues(user);
const [result] = await pool.execute<ResultSetHeader>(
`
INSERT INTO shifts
(store_id, employee_id, role_name, start_at, end_at, status, creator_admin_id, creator_employee_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
[
input.storeId,
input.employeeId,
input.roleName ?? null,
new Date(input.startAt),
new Date(input.endAt),
input.status,
creatorAdminId,
creatorEmployeeId,
],
);
return result.insertId;
},
async update(id: number, input: UpdateShiftInput): Promise<void> {
const fieldMap: Array<[keyof UpdateShiftInput, string]> = [
["storeId", "store_id"],
["employeeId", "employee_id"],
["roleName", "role_name"],
["startAt", "start_at"],
["endAt", "end_at"],
["status", "status"],
];
const sets: string[] = [];
const params: SqlParam[] = [];
for (const [key, column] of fieldMap) {
if (Object.prototype.hasOwnProperty.call(input, key)) {
sets.push(`${column} = ?`);
params.push((key === "startAt" || key === "endAt") && input[key] ? new Date(input[key]) : (input[key] as SqlParam));
}
}
if (sets.length === 0) {
return;
}
params.push(id);
await pool.execute(
`UPDATE shifts SET ${sets.join(", ")} WHERE id = ? AND deleted_at IS NULL`,
params,
);
},
async cancel(id: number): Promise<void> {
await pool.execute(
`
UPDATE shifts
SET status = 'CANCELLED', deleted_at = CURRENT_TIMESTAMP(3)
WHERE id = ? AND deleted_at IS NULL
`,
[id],
);
},
async listTodayForEmployee(employeeId: number): Promise<Shift[]> {
const [rows] = await pool.execute<ShiftRow[]>(
`
SELECT sh.*, s.name AS store_name, e.name AS employee_name
FROM shifts sh
INNER JOIN stores s ON s.id = sh.store_id
INNER JOIN employees e ON e.id = sh.employee_id
WHERE sh.employee_id = ?
AND sh.deleted_at IS NULL
AND sh.status = 'SCHEDULED'
AND DATE(sh.start_at) = CURRENT_DATE()
ORDER BY sh.start_at ASC
`,
[employeeId],
);
return rows.map(toShift);
},
};