feat: share lesson feedback with owner-only access

This commit is contained in:
richarjiang
2026-10-18 16:09:39 +08:00
parent f085852943
commit b2a785b27b
12 changed files with 310 additions and 11 deletions

View File

@@ -0,0 +1,110 @@
import { ExecutionContext, INestApplication, UnauthorizedException, ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import type { AddressInfo } from 'node:net'
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
import { RolesGuard } from '../../auth/roles.guard'
import { PrismaService } from '../../prisma/prisma.service'
import { ApiResponseInterceptor } from '../../common/interceptors/api-response.interceptor'
import { AdminMemberProgressController, MemberProgressController } from '../member-progress.controller'
import { MemberProgressService } from '../member-progress.service'
import { ProgressPhotoStorageService } from '../progress-photo-storage.service'
import { lessonFeedbackShare } from '../../../../app/src/utils/lesson-feedback'
describe('Owner-only shared lesson feedback HTTP access', () => {
let app: INestApplication
const original = { id: 'note', content: '整体评价', apparatusNotes: { M: '控制稳定', Chair: '注意节奏' }, shared: true, createdAt: new Date('2026-10-18T00:00:00Z'), bookingId: 'lesson', booking: { timeSlot: { date: new Date('2026-10-18'), startTime: '10:00' } }, user: { nickname: '学员' } }
let record: typeof original | null
const db = { memberNote: { findFirst: jest.fn(), updateMany: jest.fn() } }
async function request(method: string, path: string, identity?: string, body?: unknown, role = 'MEMBER') {
const address = app.getHttpServer().address() as AddressInfo
return fetch(`http://127.0.0.1:${address.port}${path}`, { method, headers: { 'Content-Type': 'application/json', ...(identity ? { 'x-user': identity, 'x-role': role } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
}
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [MemberProgressController, AdminMemberProgressController],
providers: [MemberProgressService, RolesGuard, { provide: PrismaService, useValue: db }, { provide: ProgressPhotoStorageService, useValue: {} }],
}).overrideGuard(JwtAuthGuard).useValue({ canActivate(context: ExecutionContext) {
const req = context.switchToHttp().getRequest()
if (!req.headers['x-user']) throw new UnauthorizedException()
req.user = { sub: req.headers['x-user'], role: req.headers['x-role'] }
return true
} }).compile()
app = module.createNestApplication()
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
app.useGlobalInterceptors(new ApiResponseInterceptor())
await app.listen(0, '127.0.0.1')
})
beforeEach(() => {
jest.clearAllMocks()
record = structuredClone(original)
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
return { count: 1 }
})
})
afterAll(async () => { await app.close() })
it('returns this one shared evaluation to its authenticated owner with no internal IDs', async () => {
const response = await request('GET', '/user/progress/notes/note', 'owner')
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('private, no-store')
const body = await response.json()
expect(body.data).toMatchObject({ nickname: '学员', note: { content: '整体评价', apparatusNotes: { M: '控制稳定', Chair: '注意节奏' } } })
expect(body.data.note.operatorId).toBeUndefined()
expect(body.data.note.userId).toBeUndefined()
expect(db.memberNote.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'note', userId: 'owner', shared: true }, select: expect.not.objectContaining({ operatorId: true, userId: true }) }))
})
it.each(['MEMBER', 'ADMIN'])('rejects a forwarded student link for a different %s account', async role => {
const response = await request('GET', '/user/progress/notes/note?userId=owner', 'other', undefined, role)
expect(response.status).toBe(404)
expect(JSON.stringify(await response.json())).not.toContain('控制稳定')
expect(db.memberNote.findFirst.mock.calls[0][0].where).toEqual({ id: 'note', userId: 'other', shared: true })
})
it.each(['private', 'deleted', 'unknown'])('does not reveal a %s note even to the owner', async kind => {
if (kind === 'private') record!.shared = false
if (kind === 'deleted') record = null
const response = await request('GET', `/user/progress/notes/${kind === 'unknown' ? 'missing' : 'note'}`, 'owner')
expect(response.status).toBe(404)
expect((await response.json()).message).toBe('评价不存在或你无权查看')
})
it.each(['/user/progress/notes/note', '/admin/members/owner/progress/notes/note'])('requires authentication: %s', async path => {
expect((await request('GET', path)).status).toBe(401)
expect(db.memberNote.findFirst).not.toHaveBeenCalled()
})
it('allows an authorized teacher preview of a private note, scoped to the target member', async () => {
record!.shared = false
expect((await request('GET', '/admin/members/owner/progress/notes/note', 'teacher', undefined, 'ADMIN')).status).toBe(200)
expect(db.memberNote.findFirst.mock.calls[0][0].where).toEqual({ id: 'note', userId: 'owner' })
expect((await request('GET', '/admin/members/other/progress/notes/note', 'teacher', undefined, 'ADMIN')).status).toBe(404)
})
it('does not let members obtain preview access or change sharing by forging an admin URL', async () => {
expect((await request('GET', '/admin/members/owner/progress/notes/note', 'other')).status).toBe(403)
expect((await request('PUT', '/admin/members/owner/progress/notes/note/sharing', 'owner', { shared: true })).status).toBe(403)
expect(db.memberNote.findFirst).not.toHaveBeenCalled()
expect(db.memberNote.updateMany).not.toHaveBeenCalled()
})
it('revokes existing links immediately and lets a teacher re-enable the same note', async () => {
const endpoint = '/admin/members/owner/progress/notes/note/sharing'
expect((await request('PUT', endpoint, 'teacher', { shared: false }, 'ADMIN')).status).toBe(200)
expect((await request('GET', '/user/progress/notes/note', 'owner')).status).toBe(404)
expect((await request('PUT', endpoint, 'teacher', { shared: true }, 'ADMIN')).status).toBe(200)
expect((await request('GET', '/user/progress/notes/note', 'owner')).status).toBe(200)
expect(record!.apparatusNotes).toEqual(original.apparatusNotes)
expect(db.memberNote.updateMany).toHaveBeenCalledWith({ where: { id: 'note', userId: 'owner' }, data: { shared: false } })
})
it.each([{}, { shared: 'true' }, { shared: 1 }, { shared: null }, { shared: true, userId: 'other' }])('rejects malformed sharing updates %j', async body => {
expect((await request('PUT', '/admin/members/owner/progress/notes/note/sharing', 'teacher', body, 'ADMIN')).status).toBe(400)
expect(db.memberNote.updateMany).not.toHaveBeenCalled()
})
it('does not change a note through a mismatched member path', async () => {
expect((await request('PUT', '/admin/members/other/progress/notes/note/sharing', 'teacher', { shared: false }, 'ADMIN')).status).toBe(404)
expect(record!.shared).toBe(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')
expect(lessonFeedbackShare().path).toBe('/pages/home/index')
})
})

View File

@@ -19,6 +19,7 @@ export class MemberNoteDto {
@IsBoolean() shared!: boolean
@IsOptional() @IsString() @MinLength(1) bookingId?: string
}
export class NoteSharingDto { @IsBoolean() shared!: boolean }
export class ProgressPhotoDto {
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string
@IsString() @MaxLength(200) caption!: string

View File

@@ -1,15 +1,16 @@
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common'
import { Body, Controller, Delete, Get, Header, Param, Post, Put, UseGuards } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard'
import { Roles } from '../auth/roles.decorator'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { MemberProgressService } from './member-progress.service'
import { BodyMetricDto, MemberNoteDto, PhotoConsentDto, ProgressPhotoDto } from './dto/member-progress.dto'
import { BodyMetricDto, MemberNoteDto, NoteSharingDto, PhotoConsentDto, ProgressPhotoDto } from './dto/member-progress.dto'
@Controller('user/progress') @UseGuards(JwtAuthGuard)
export class MemberProgressController {
constructor(private readonly service: MemberProgressService) {}
@Get() archive(@CurrentUser('sub') userId: string) { return this.service.archive(userId) }
@Get('notes/:id') @Header('Cache-Control', 'private, no-store') note(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.note(userId, id) }
@Get('metrics/:id') metric(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.metric(userId, id) }
@Post('photos/:id/consent') consent(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: PhotoConsentDto) { return this.service.consent(userId, id, dto.consent) }
@Get('photos/:id/url') url(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.photoUrl(userId, id) }
@@ -23,6 +24,8 @@ export class AdminMemberProgressController {
@Get('metrics/:id') metricDetail(@Param('userId') userId: string, @Param('id') id: string) { return this.service.metric(userId, id) }
@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/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) }
@Post('photos/:id/complete') complete(@Param('userId') userId: string, @Param('id') id: string) { return this.service.finishUpload(userId, id) }

View File

@@ -61,6 +61,25 @@ export class MemberProgressService {
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 } })
}
async note(userId: string, id: string, admin = false) {
const record = await this.prisma.memberNote.findFirst({
where: { id, userId, ...(admin ? {} : { shared: true }) },
select: {
id: true, content: true, apparatusNotes: true, shared: true, createdAt: true, bookingId: true,
booking: { select: { timeSlot: { select: { date: true, startTime: true } } } },
user: { select: { nickname: true } },
},
})
// A forwarded link must not reveal whether someone else's note exists.
if (!record) throw new NotFoundException('评价不存在或你无权查看')
const { user, ...note } = record
return { nickname: user.nickname, note }
}
async setNoteSharing(userId: string, id: string, shared: boolean) {
const result = await this.prisma.memberNote.updateMany({ where: { id, userId }, data: { shared } })
if (!result.count) throw new NotFoundException('评价不存在或不属于该学员')
return { shared }
}
async remove(userId: string, kind: string, id: string) {
if (kind === 'metrics') {
const result = await this.prisma.bodyMetric.deleteMany({ where: { id, userId } })