Files
plates-server/src/checkins/checkins.service.ts

246 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 };
}
}