246 lines
8.9 KiB
TypeScript
246 lines
8.9 KiB
TypeScript
import { Injectable, NotFoundException, Logger, ForbiddenException } from '@nestjs/common';
|
||
import { InjectModel } from '@nestjs/sequelize';
|
||
import { Checkin, CheckinStatus } from './models/checkin.model';
|
||
import { CreateCheckinDto, UpdateCheckinDto, CompleteCheckinDto, RemoveCheckinDto, CheckinResponseDto, GetDailyStatusRangeQueryDto, DailyStatusItem } from './dto/checkin.dto';
|
||
import { ResponseCode } from '../base.dto';
|
||
import * as dayjs from 'dayjs';
|
||
import { Op } from 'sequelize';
|
||
import { ActivityLogsService } from '../activity-logs/activity-logs.service';
|
||
import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
|
||
|
||
@Injectable()
|
||
export class CheckinsService {
|
||
private readonly logger = new Logger(CheckinsService.name);
|
||
|
||
constructor(
|
||
@InjectModel(Checkin)
|
||
private readonly checkinModel: typeof Checkin,
|
||
private readonly activityLogsService: ActivityLogsService,
|
||
) { }
|
||
|
||
async create(dto: CreateCheckinDto, userId: string): Promise<CheckinResponseDto> {
|
||
// 检查是否已存在未删除的记录
|
||
const existingRecord = await this.checkinModel.findOne({
|
||
where: {
|
||
userId,
|
||
workoutId: dto.workoutId || null,
|
||
planId: dto.planId || null,
|
||
checkinDate: dto.checkinDate || null,
|
||
deleted: false,
|
||
},
|
||
});
|
||
|
||
// 存在则更新
|
||
if (existingRecord) {
|
||
await this.update({ ...dto, id: existingRecord.id! }, userId);
|
||
return { code: ResponseCode.SUCCESS, message: 'success', data: existingRecord.toJSON() };
|
||
}
|
||
|
||
const record = await this.checkinModel.create({
|
||
userId,
|
||
workoutId: dto.workoutId || null,
|
||
planId: dto.planId || null,
|
||
title: dto.title || null,
|
||
checkinDate: dto.checkinDate || null,
|
||
startedAt: dto.startedAt ? new Date(dto.startedAt) : null,
|
||
notes: dto.notes || null,
|
||
metrics: dto.metrics || null,
|
||
status: CheckinStatus.PENDING,
|
||
});
|
||
|
||
await this.activityLogsService.record({
|
||
userId,
|
||
entityType: ActivityEntityType.CHECKIN,
|
||
action: ActivityActionType.CREATE,
|
||
entityId: record.id,
|
||
changes: record.toJSON(),
|
||
});
|
||
|
||
return { code: ResponseCode.SUCCESS, message: 'success', data: record.toJSON() };
|
||
}
|
||
|
||
async update(dto: UpdateCheckinDto, userId: string): Promise<CheckinResponseDto> {
|
||
const record = await this.checkinModel.findOne({
|
||
where: {
|
||
id: dto.id,
|
||
deleted: false,
|
||
},
|
||
});
|
||
if (!record) {
|
||
throw new NotFoundException('打卡记录不存在');
|
||
}
|
||
if (record.userId !== userId) {
|
||
throw new ForbiddenException('无权操作该打卡记录');
|
||
}
|
||
|
||
const changes: Record<string, any> = {};
|
||
if (dto.workoutId !== undefined) { record.workoutId = dto.workoutId; changes.workoutId = dto.workoutId; }
|
||
if (dto.planId !== undefined) { record.planId = dto.planId; changes.planId = dto.planId; }
|
||
if (dto.title !== undefined) { record.title = dto.title; changes.title = dto.title; }
|
||
if (dto.checkinDate !== undefined) { record.checkinDate = dto.checkinDate as any; changes.checkinDate = dto.checkinDate; }
|
||
if (dto.startedAt !== undefined) { record.startedAt = dto.startedAt ? new Date(dto.startedAt) : null; changes.startedAt = dto.startedAt; }
|
||
if (dto.notes !== undefined) { record.notes = dto.notes; changes.notes = dto.notes; }
|
||
if (dto.metrics !== undefined) { record.metrics = dto.metrics as any; changes.metrics = dto.metrics; }
|
||
if (dto.status !== undefined) { record.status = dto.status; changes.status = dto.status; }
|
||
if (dto.completedAt !== undefined) { record.completedAt = dto.completedAt ? new Date(dto.completedAt) : null; changes.completedAt = dto.completedAt; }
|
||
if (dto.durationSeconds !== undefined) { record.durationSeconds = dto.durationSeconds; changes.durationSeconds = dto.durationSeconds; }
|
||
|
||
await record.save();
|
||
await this.activityLogsService.record({
|
||
userId: record.userId,
|
||
entityType: ActivityEntityType.CHECKIN,
|
||
action: ActivityActionType.UPDATE,
|
||
entityId: record.id,
|
||
changes,
|
||
});
|
||
return { code: ResponseCode.SUCCESS, message: 'success', data: record.toJSON() };
|
||
}
|
||
|
||
async complete(dto: CompleteCheckinDto, userId: string): Promise<CheckinResponseDto> {
|
||
const record = await this.checkinModel.findOne({
|
||
where: {
|
||
id: dto.id,
|
||
deleted: false,
|
||
},
|
||
});
|
||
if (!record) {
|
||
throw new NotFoundException('打卡记录不存在');
|
||
}
|
||
if (record.userId !== userId) {
|
||
throw new ForbiddenException('无权操作该打卡记录');
|
||
}
|
||
|
||
record.status = CheckinStatus.COMPLETED;
|
||
record.completedAt = dto.completedAt ? new Date(dto.completedAt) : new Date();
|
||
if (dto.durationSeconds !== undefined) record.durationSeconds = dto.durationSeconds;
|
||
if (dto.notes !== undefined) record.notes = dto.notes;
|
||
if (dto.metrics !== undefined) record.metrics = dto.metrics as any;
|
||
|
||
await record.save();
|
||
await this.activityLogsService.record({
|
||
userId: record.userId,
|
||
entityType: ActivityEntityType.CHECKIN,
|
||
action: ActivityActionType.UPDATE,
|
||
entityId: record.id,
|
||
changes: {
|
||
status: record.status,
|
||
completedAt: record.completedAt,
|
||
durationSeconds: record.durationSeconds,
|
||
notes: record.notes,
|
||
metrics: record.metrics,
|
||
},
|
||
});
|
||
return { code: ResponseCode.SUCCESS, message: 'success', data: record.toJSON() };
|
||
}
|
||
|
||
async remove(dto: RemoveCheckinDto, userId: string): Promise<CheckinResponseDto> {
|
||
const record = await this.checkinModel.findByPk(dto.id);
|
||
if (!record) {
|
||
throw new NotFoundException('打卡记录不存在');
|
||
}
|
||
if (record.userId !== userId) {
|
||
throw new ForbiddenException('无权操作该打卡记录');
|
||
}
|
||
record.deleted = true;
|
||
await record.save();
|
||
await this.activityLogsService.record({
|
||
userId: record.userId,
|
||
entityType: ActivityEntityType.CHECKIN,
|
||
action: ActivityActionType.DELETE,
|
||
entityId: record.id,
|
||
changes: null,
|
||
});
|
||
return { code: ResponseCode.SUCCESS, message: 'success', data: { id: dto.id } };
|
||
}
|
||
|
||
async getDaily(userId: string, date?: string): Promise<CheckinResponseDto> {
|
||
const target = date ? dayjs(date) : dayjs();
|
||
if (!target.isValid()) {
|
||
return { code: ResponseCode.ERROR, message: '无效日期', data: [] };
|
||
}
|
||
const start = target.startOf('day').toDate();
|
||
const end = target.endOf('day').toDate();
|
||
|
||
// 覆盖两类数据:
|
||
// 1) checkinDate == YYYY-MM-DD(精确日)
|
||
// 2) startedAt/ completedAt 落在该日范围内(更鲁棒)
|
||
const rows = await this.checkinModel.findAll({
|
||
where: {
|
||
userId,
|
||
deleted: false,
|
||
[Op.or]: [
|
||
{ checkinDate: target.format('YYYY-MM-DD') as any },
|
||
{
|
||
[Op.or]: [
|
||
{ startedAt: { [Op.between]: [start, end] } },
|
||
{ completedAt: { [Op.between]: [start, end] } },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
order: [['createdAt', 'ASC']],
|
||
});
|
||
|
||
return { code: ResponseCode.SUCCESS, message: 'success', data: rows.map(r => r.toJSON()) };
|
||
}
|
||
|
||
// 按时间范围返回每天是否打卡(任一记录满足视为已打卡)
|
||
async getDailyStatusRange(userId: string, query: GetDailyStatusRangeQueryDto): Promise<CheckinResponseDto<DailyStatusItem[]>> {
|
||
const start = dayjs(query.startDate, 'YYYY-MM-DD');
|
||
const end = dayjs(query.endDate, 'YYYY-MM-DD');
|
||
if (!start.isValid() || !end.isValid() || end.isBefore(start)) {
|
||
return { code: ResponseCode.ERROR, message: '无效日期范围', data: [] };
|
||
}
|
||
|
||
// 查询范围内所有打卡(覆盖checkinDate与时间戳)
|
||
const startTs = start.startOf('day').toDate();
|
||
const endTs = end.endOf('day').toDate();
|
||
|
||
const rows = await this.checkinModel.findAll({
|
||
where: {
|
||
userId,
|
||
deleted: false,
|
||
[Op.or]: [
|
||
{
|
||
checkinDate: {
|
||
[Op.between]: [start.format('YYYY-MM-DD') as any, end.format('YYYY-MM-DD') as any],
|
||
} as any,
|
||
},
|
||
{
|
||
[Op.or]: [
|
||
{ startedAt: { [Op.between]: [startTs, endTs] } },
|
||
{ completedAt: { [Op.between]: [startTs, endTs] } },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
attributes: ['checkinDate', 'startedAt', 'completedAt'],
|
||
});
|
||
|
||
const set = new Set<string>();
|
||
for (const r of rows) {
|
||
if (r.checkinDate) {
|
||
set.add(r.checkinDate);
|
||
}
|
||
if (r.startedAt) {
|
||
set.add(dayjs(r.startedAt).format('YYYY-MM-DD'));
|
||
}
|
||
if (r.completedAt) {
|
||
set.add(dayjs(r.completedAt).format('YYYY-MM-DD'));
|
||
}
|
||
}
|
||
|
||
const result: DailyStatusItem[] = [];
|
||
let cur = start.clone();
|
||
while (!cur.isAfter(end)) {
|
||
const d = cur.format('YYYY-MM-DD');
|
||
result.push({ date: d, checkedIn: set.has(d) });
|
||
cur = cur.add(1, 'day');
|
||
}
|
||
|
||
return { code: ResponseCode.SUCCESS, message: 'success', data: result };
|
||
}
|
||
}
|
||
|
||
|