feat: 会员档案寄语改名为课后总结并支持教练编辑已评价信息

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-10-21 11:17:14 +08:00
parent 3dc56a113b
commit 819367178a
8 changed files with 160 additions and 31 deletions

View File

@@ -40,7 +40,9 @@ describe('Owner-only shared lesson feedback HTTP access', () => {
db.memberNote.findFirst.mockImplementation(async ({ where }) => record && where.id === record.id && where.userId === 'owner' && (where.shared === undefined || where.shared === record.shared) ? record : null)
db.memberNote.updateMany.mockImplementation(async ({ where, data }) => {
if (!record || where.userId !== 'owner' || where.id !== record.id) return { count: 0 }
record.shared = data.shared
if (data.content !== undefined) record.content = data.content
if (data.apparatusNotes !== undefined) record.apparatusNotes = data.apparatusNotes
if (data.shared !== undefined) record.shared = data.shared
return { count: 1 }
})
})
@@ -102,6 +104,23 @@ describe('Owner-only shared lesson feedback HTTP access', () => {
expect((await request('PUT', '/admin/members/other/progress/notes/note/sharing', 'teacher', { shared: false }, 'ADMIN')).status).toBe(404)
expect(record!.shared).toBe(true)
})
it('allows an admin to edit an existing note and blocks member or cross-member edits', async () => {
expect((await request('PUT', '/admin/members/owner/progress/notes/note', 'owner', { content: '篡改', shared: true })).status).toBe(403)
expect((await request('PUT', '/admin/members/other/progress/notes/note', 'teacher', { content: '跨学员修改', shared: true }, 'ADMIN')).status).toBe(404)
const editRes = await request('PUT', '/admin/members/owner/progress/notes/note', 'teacher', {
content: '更新后的课后总结',
apparatusNotes: { R: '核心控制提升' },
shared: true,
}, 'ADMIN')
expect(editRes.status).toBe(200)
const readRes = await request('GET', '/user/progress/notes/note', 'owner')
expect(readRes.status).toBe(200)
expect((await readRes.json()).data.note).toMatchObject({
content: '更新后的课后总结',
apparatusNotes: { R: '核心控制提升' },
shared: true,
})
})
it('shares a student-only URL with a generic title and static cover instead of personal content', () => {
expect(lessonFeedbackShare('note')).toEqual({ title: '教练为你记录了一份课后评价', path: '/pages/profile/lesson-feedback?id=note', imageUrl: '/static/share/lesson-feedback.png' })
expect(lessonFeedbackShare('note&userId=owner').path).toBe('/pages/profile/lesson-feedback?id=note%26userId%3Downer')

View File

@@ -10,7 +10,7 @@ describe('Coach lesson feedback', () => {
const db = {
user: { findUnique: jest.fn() },
booking: { findFirst: jest.fn() },
memberNote: { create: jest.fn() },
memberNote: { create: jest.fn(), updateMany: jest.fn(), findFirst: jest.fn() },
}
let service: MemberProgressService
beforeEach(() => {
@@ -69,4 +69,39 @@ describe('Coach lesson feedback', () => {
await expect(service.addNote('member', 'coach', { shared: true, bookingId: 'foreign', apparatusNotes: { M: '完成训练' } })).rejects.toThrow('只能批注该学员已完成的课程')
expect(db.memberNote.create).not.toHaveBeenCalled()
})
it('updates an existing note in place while preserving linked booking and removing cleared apparatus fields', async () => {
db.memberNote.updateMany.mockResolvedValue({ count: 1 })
db.memberNote.findFirst.mockResolvedValue({
id: 'note-1',
content: '更新后的整体总结',
apparatusNotes: { R: '核心更稳' },
shared: true,
bookingId: 'lesson',
})
const updated = await service.updateNote('member', 'note-1', 'coach-2', {
content: ' 更新后的整体总结 ',
shared: true,
apparatusNotes: { M: ' ', R: ' 核心更稳 ' },
})
expect(db.memberNote.updateMany).toHaveBeenCalledWith({
where: { id: 'note-1', userId: 'member' },
data: {
content: '更新后的整体总结',
apparatusNotes: { R: '核心更稳' },
shared: true,
operatorId: 'coach-2',
},
})
expect(updated).toMatchObject({ id: 'note-1', content: '更新后的整体总结', apparatusNotes: { R: '核心更稳' }, shared: true })
})
it('rejects empty or foreign note updates before modifying records', async () => {
await expect(service.updateNote('member', 'note-1', 'coach', { content: ' ', shared: false, apparatusNotes: { M: ' ' } })).rejects.toThrow('请填写整体评价或至少一项器械记录')
expect(db.memberNote.updateMany).not.toHaveBeenCalled()
db.memberNote.updateMany.mockResolvedValue({ count: 0 })
await expect(service.updateNote('other', 'note-1', 'coach', { content: '有效内容', shared: false })).rejects.toThrow('评价不存在或不属于该学员')
expect(db.memberNote.updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'note-1', userId: 'other' } }))
})
})

View File

@@ -198,10 +198,14 @@ describe('Member progress authorization', () => {
})
it('restricts record editing to admins and exposes no member write route', () => {
const handler = AdminMemberProgressController.prototype.updateMetric
const noteHandler = AdminMemberProgressController.prototype.updateNote
const guard = new RolesGuard(new Reflector())
const context = (role: string) => ({ getHandler: () => handler, getClass: () => AdminMemberProgressController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
const context = (role: string, target: unknown = handler) => ({ getHandler: () => target, getClass: () => AdminMemberProgressController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
expect(guard.canActivate(context('MEMBER'))).toBe(false)
expect(guard.canActivate(context('ADMIN'))).toBe(true)
expect(guard.canActivate(context('MEMBER', noteHandler))).toBe(false)
expect(guard.canActivate(context('ADMIN', noteHandler))).toBe(true)
expect('updateMetric' in MemberProgressController.prototype).toBe(false)
expect('updateNote' in MemberProgressController.prototype).toBe(false)
})
})

View File

@@ -25,6 +25,7 @@ export class AdminMemberProgressController {
@Put('metrics/:id') updateMetric(@Param('userId') userId: string, @Param('id') id: string, @CurrentUser('sub') operatorId: string, @Body() dto: BodyMetricDto) { return this.service.updateMetric(userId, id, operatorId, dto) }
@Post('notes') note(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: MemberNoteDto) { return this.service.addNote(userId, operatorId, dto) }
@Get('notes/:id') @Header('Cache-Control', 'private, no-store') noteDetail(@Param('userId') userId: string, @Param('id') id: string) { return this.service.note(userId, id, true) }
@Put('notes/:id') updateNote(@Param('userId') userId: string, @Param('id') id: string, @CurrentUser('sub') operatorId: string, @Body() dto: MemberNoteDto) { return this.service.updateNote(userId, id, operatorId, dto) }
@Put('notes/:id/sharing') shareNote(@Param('userId') userId: string, @Param('id') id: string, @Body() dto: NoteSharingDto) { return this.service.setNoteSharing(userId, id, dto.shared) }
@Delete(':kind/:id') remove(@Param('userId') userId: string, @Param('kind') kind: string, @Param('id') id: string) { return this.service.remove(userId, kind, id) }
@Post('photos/upload') upload(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: ProgressPhotoDto) { return this.service.upload(userId, operatorId, dto) }

View File

@@ -54,12 +54,28 @@ export class MemberProgressService {
}
async addNote(userId: string, operatorId: string, dto: MemberNoteDto) {
await this.member(userId)
const data = await this.noteData(userId, dto)
return this.prisma.memberNote.create({ data: { ...data, userId, operatorId, bookingId: dto.bookingId || null } })
}
async updateNote(userId: string, id: string, operatorId: string, dto: MemberNoteDto) {
const data = await this.noteData(userId, dto)
const result = await this.prisma.memberNote.updateMany({
where: { id, userId },
data: { ...data, operatorId, ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId || null } : {}) },
})
if (!result.count) throw new NotFoundException('评价不存在或不属于该学员')
return (await this.prisma.memberNote.findFirst?.({
where: { id, userId },
include: { booking: { select: { timeSlot: { select: { date: true, startTime: true } } } } },
})) ?? { id, userId, operatorId, ...data }
}
private async noteData(userId: string, dto: MemberNoteDto) {
if (dto.apparatusNotes !== undefined && !isApparatusNotes(dto.apparatusNotes)) throw new BadRequestException('器械记录内容无效')
const content = dto.content?.trim() || ''
const apparatusNotes = Object.fromEntries(filledApparatusNotes(dto.apparatusNotes).map(note => [note.apparatus, note.content]))
if (!content && !Object.keys(apparatusNotes).length) throw new BadRequestException('请填写整体评价或至少一项器械记录')
if (dto.bookingId && !await this.prisma.booking.findFirst({ where: { id: dto.bookingId, userId, status: 'COMPLETED' } })) throw new BadRequestException('只能批注该学员已完成的课程')
return this.prisma.memberNote.create({ data: { userId, operatorId, content, apparatusNotes, shared: dto.shared, bookingId: dto.bookingId || null } })
return { content, apparatusNotes, shared: dto.shared }
}
async note(userId: string, id: string, admin = false) {
const record = await this.prisma.memberNote.findFirst({