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 { const [rows] = await pool.execute( ` 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 { 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( ` 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( ` 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( ` 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 { const [rows] = await pool.execute( ` 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 { const [creatorAdminId, creatorEmployeeId] = actorValues(user); const [result] = await pool.execute( ` 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 { 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 { 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 { const [rows] = await pool.execute( ` 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); }, };