feat(v2.4): 完成领域主写迁移

This commit is contained in:
2026-07-08 15:41:10 +08:00
parent 2358dbd0b5
commit da71128ccd
42 changed files with 1410 additions and 81 deletions

View File

@@ -0,0 +1,9 @@
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "username" TEXT,
ADD COLUMN IF NOT EXISTS "department_id" TEXT,
ADD COLUMN IF NOT EXISTS "role_id" TEXT NOT NULL DEFAULT 'member',
ADD COLUMN IF NOT EXISTS "phone" TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "password" TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "is_system" BOOLEAN NOT NULL DEFAULT false;
CREATE UNIQUE INDEX IF NOT EXISTS "users_username_key" ON "users"("username");

View File

@@ -8,12 +8,18 @@ datasource db {
}
model User {
id String @id @default(cuid())
email String @unique
name String
avatar String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id String @id @default(cuid())
email String @unique
name String
avatar String?
username String? @unique
departmentId String? @map("department_id")
roleId String @default("member") @map("role_id")
phone String @default("")
password String @default("")
isSystem Boolean @default(false) @map("is_system")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
createdTasks Task[] @relation("TaskCreator")
assignedTasks Task[] @relation("TaskAssignee")

View File

@@ -10,6 +10,10 @@ import { VersionPlanModule } from './modules/version-plan/version-plan.module';
import { DevTaskModule } from './modules/dev-task/dev-task.module';
import { TestCaseModule } from './modules/test-case/test-case.module';
import { BugModule } from './modules/bug/bug.module';
import { MemberModule } from './modules/member/member.module';
import { TaskCategoryModule } from './modules/task-category/task-category.module';
import { TaskWorklogModule } from './modules/task-worklog/task-worklog.module';
import { OvertimeModule } from './modules/overtime/overtime.module';
import { AiModule } from './modules/ai/ai.module';
import { ConfigModule } from './modules/config/config.module';
import { DataModule } from './modules/data/data.module';
@@ -27,6 +31,10 @@ import { HealthModule } from './modules/health/health.module';
DevTaskModule,
TestCaseModule,
BugModule,
MemberModule,
TaskCategoryModule,
TaskWorklogModule,
OvertimeModule,
RequirementModule,
ConfigModule,
DataModule,

View File

@@ -0,0 +1,34 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
export class CreateMemberDto {
@IsString()
name!: string;
@IsString()
@IsOptional()
username?: string;
@IsString()
@IsOptional()
departmentId?: string;
@IsString()
@IsOptional()
roleId?: string;
@IsString()
@IsOptional()
phone?: string;
@IsString()
@IsOptional()
email?: string;
@IsString()
@IsOptional()
password?: string;
@IsBoolean()
@IsOptional()
isSystem?: boolean;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateMemberDto } from './create-member.dto';
export class UpdateMemberDto extends PartialType(CreateMemberDto) {}

View File

@@ -0,0 +1,29 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { CreateMemberDto } from './dto/create-member.dto';
import { UpdateMemberDto } from './dto/update-member.dto';
import { MemberService } from './member.service';
@Controller('members')
export class MemberController {
constructor(private readonly memberService: MemberService) {}
@Get()
findAll() {
return this.memberService.findAll();
}
@Post()
create(@Body() dto: CreateMemberDto) {
return this.memberService.create(dto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateMemberDto) {
return this.memberService.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.memberService.remove(id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { MemberController } from './member.controller';
import { MemberService } from './member.service';
@Module({
controllers: [MemberController],
providers: [MemberService],
exports: [MemberService],
})
export class MemberModule {}

View File

@@ -0,0 +1,94 @@
import { BadRequestException } from '@nestjs/common';
import { MemberService } from './member.service';
describe('MemberService domain writes', () => {
const makeService = () => {
const prisma = {
user: {
create: jest.fn(),
delete: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
};
return { prisma, service: new MemberService(prisma as any) };
};
it('creates members with UI role and department fields in users', async () => {
const { prisma, service } = makeService();
prisma.user.create.mockResolvedValue({
id: 'member-1',
name: '张三',
username: 'zhangsan',
departmentId: 'dept-2',
roleId: 'role-dev',
phone: '13000000000',
email: 'zhangsan@example.com',
password: 'Ftb12345',
isSystem: false,
createdAt: new Date('2026-07-08T00:00:00.000Z'),
});
await service.create({
name: '张三',
username: 'zhangsan',
departmentId: 'dept-2',
roleId: 'role-dev',
phone: '13000000000',
email: 'zhangsan@example.com',
password: 'Ftb12345',
});
expect(prisma.user.create).toHaveBeenCalledWith({
data: expect.objectContaining({
name: '张三',
username: 'zhangsan',
departmentId: 'dept-2',
roleId: 'role-dev',
phone: '13000000000',
email: 'zhangsan@example.com',
password: 'Ftb12345',
}),
});
});
it('protects the built-in admin from deletion and privileged field edits', async () => {
const { prisma, service } = makeService();
prisma.user.update.mockResolvedValue({
id: 'm-8',
name: '超级管理员',
username: 'admin',
roleId: 'role-admin',
departmentId: '',
phone: '13200132009',
email: 'admin@example.com',
password: 'Ftb12345',
isSystem: true,
createdAt: new Date('2026-07-08T00:00:00.000Z'),
});
await expect(service.remove('m-8')).rejects.toBeInstanceOf(BadRequestException);
await service.update('m-8', {
name: '误改',
username: 'other',
roleId: 'role-viewer',
departmentId: 'dept-1',
phone: '13200132009',
});
expect(prisma.user.delete).not.toHaveBeenCalled();
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'm-8' },
data: expect.not.objectContaining({
name: '误改',
username: 'other',
roleId: 'role-viewer',
departmentId: 'dept-1',
}),
});
expect(prisma.user.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ phone: '13200132009', isSystem: true }),
}));
});
});

View File

@@ -0,0 +1,95 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateMemberDto } from './dto/create-member.dto';
import { UpdateMemberDto } from './dto/update-member.dto';
const SYSTEM_ADMIN_MEMBER_ID = 'm-8';
const SYSTEM_ADMIN_NAME = '超级管理员';
const SYSTEM_ADMIN_USERNAME = 'admin';
const SYSTEM_ADMIN_ROLE_ID = 'role-admin';
@Injectable()
export class MemberService {
constructor(private readonly prisma: PrismaService) {}
async findAll() {
const rows = await (this.prisma.user as any).findMany({ orderBy: [{ isSystem: 'desc' }, { name: 'asc' }] });
return rows.map(toMember);
}
async create(dto: CreateMemberDto) {
const data = toUserData(dto);
const row = await (this.prisma.user as any).create({ data });
return toMember(row);
}
async update(id: string, dto: UpdateMemberDto) {
const data = id === SYSTEM_ADMIN_MEMBER_ID
? { ...sanitizeSystemAdminPatch(dto), isSystem: true }
: toUserData(dto, { partial: true });
const row = await (this.prisma.user as any).update({ where: { id }, data });
return toMember(row);
}
async remove(id: string) {
if (id === SYSTEM_ADMIN_MEMBER_ID) {
throw new BadRequestException('系统内置超级管理员不可删除');
}
await (this.prisma.user as any).delete({ where: { id } });
return { deleted: true };
}
}
function sanitizeSystemAdminPatch(dto: UpdateMemberDto) {
const data = toUserData(dto, { partial: true });
delete (data as any).name;
delete (data as any).username;
delete (data as any).departmentId;
delete (data as any).roleId;
delete (data as any).email;
return data;
}
function toUserData(dto: Partial<CreateMemberDto>, options: { partial?: boolean } = {}) {
const id = (dto as any).id as string | undefined;
const email = dto.email?.trim() || (options.partial ? undefined : `${id || dto.username || Date.now()}@local.ftb`);
return {
...(id !== undefined && { id }),
...(dto.name !== undefined && { name: dto.name }),
...(dto.username !== undefined && { username: emptyToNull(dto.username) }),
...(dto.departmentId !== undefined && { departmentId: emptyToNull(dto.departmentId) }),
...(dto.roleId !== undefined && { roleId: dto.roleId || 'member' }),
...(dto.phone !== undefined && { phone: dto.phone ?? '' }),
...(email !== undefined && { email }),
...(dto.password !== undefined && { password: dto.password ?? '' }),
...(dto.isSystem !== undefined && { isSystem: dto.isSystem }),
};
}
function toMember(row: any) {
return {
id: row.id,
name: row.id === SYSTEM_ADMIN_MEMBER_ID ? SYSTEM_ADMIN_NAME : row.name,
username: row.id === SYSTEM_ADMIN_MEMBER_ID ? SYSTEM_ADMIN_USERNAME : row.username ?? undefined,
departmentId: row.id === SYSTEM_ADMIN_MEMBER_ID ? '' : row.departmentId ?? '',
roleId: row.id === SYSTEM_ADMIN_MEMBER_ID ? SYSTEM_ADMIN_ROLE_ID : row.roleId ?? 'member',
phone: row.phone ?? '',
email: row.email?.endsWith('@local.ftb') ? '' : row.email ?? '',
password: row.password ?? '',
createdAt: toIsoDate(row.createdAt),
isSystem: Boolean(row.isSystem) || row.id === SYSTEM_ADMIN_MEMBER_ID,
};
}
function emptyToNull(value: string | null | undefined): string | null {
if (value === null) return null;
if (value === undefined) return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function toIsoDate(value: unknown): string {
if (value instanceof Date) return value.toISOString().slice(0, 10);
if (typeof value === 'string' && value) return value.slice(0, 10);
return new Date().toISOString().slice(0, 10);
}

View File

@@ -0,0 +1,34 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateOvertimeDto {
@IsString()
@IsOptional()
productId?: string;
@IsString()
@IsOptional()
projectId?: string;
@IsString()
@IsOptional()
versionId?: string;
@IsString()
person!: string;
@IsString()
startTime!: string;
@IsString()
endTime!: string;
@IsOptional()
duration?: number;
@IsString()
reasonId!: string;
@IsString()
@IsOptional()
remark?: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateOvertimeDto } from './create-overtime.dto';
export class UpdateOvertimeDto extends PartialType(CreateOvertimeDto) {}

View File

@@ -0,0 +1,29 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { CreateOvertimeDto } from './dto/create-overtime.dto';
import { UpdateOvertimeDto } from './dto/update-overtime.dto';
import { OvertimeService } from './overtime.service';
@Controller('overtime')
export class OvertimeController {
constructor(private readonly overtimeService: OvertimeService) {}
@Get()
findAll() {
return this.overtimeService.findAll();
}
@Post()
create(@Body() dto: CreateOvertimeDto) {
return this.overtimeService.create(dto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateOvertimeDto) {
return this.overtimeService.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.overtimeService.remove(id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { WorkActivityModule } from '../work-activity/work-activity.module';
import { OvertimeController } from './overtime.controller';
import { OvertimeService } from './overtime.service';
@Module({
imports: [WorkActivityModule],
controllers: [OvertimeController],
providers: [OvertimeService],
exports: [OvertimeService],
})
export class OvertimeModule {}

View File

@@ -0,0 +1,37 @@
import { OvertimeService } from './overtime.service';
describe('OvertimeService domain writes', () => {
it('writes overtime records to relation table', async () => {
const prisma = {
overtimeRecord: {
create: jest.fn().mockResolvedValue({ id: 'ot-1' }),
delete: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
};
const service = new OvertimeService(prisma as any);
await service.create({
projectId: 'project-1',
versionId: 'version-1',
person: 'member-1',
startTime: '2026-07-08T19:00:00.000Z',
endTime: '2026-07-08T21:00:00.000Z',
duration: 2,
reasonId: 'reason-4',
});
expect(prisma.overtimeRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({
projectId: 'project-1',
versionId: 'version-1',
userId: 'member-1',
reason: 'reason-4',
startAt: new Date('2026-07-08T19:00:00.000Z'),
endAt: new Date('2026-07-08T21:00:00.000Z'),
hours: 2,
}),
});
});
});

View File

@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { WorkActivityService } from '../work-activity/work-activity.service';
import { CreateOvertimeDto } from './dto/create-overtime.dto';
import { UpdateOvertimeDto } from './dto/update-overtime.dto';
@Injectable()
export class OvertimeService {
constructor(
private readonly prisma: PrismaService,
private readonly workActivity?: WorkActivityService,
) {}
async create(dto: CreateOvertimeDto) {
const item = await this.prisma.overtimeRecord.create({
data: {
productId: dto.productId || null,
projectId: dto.projectId || null,
versionId: dto.versionId || null,
userId: dto.person,
reason: dto.remark ? `${dto.reasonId}:${dto.remark}` : dto.reasonId,
startAt: new Date(dto.startTime),
endAt: new Date(dto.endTime),
hours: dto.duration ?? 0,
},
});
if (dto.versionId) await this.workActivity?.markXiaobaoSummaryDirty(dto.versionId);
return item;
}
findAll() {
return this.prisma.overtimeRecord.findMany({ orderBy: { createdAt: 'desc' } });
}
update(id: string, dto: UpdateOvertimeDto) {
return this.prisma.overtimeRecord.updateMany({ where: { id }, data: toData(dto) });
}
async remove(id: string) {
await this.prisma.overtimeRecord.deleteMany({ where: { id } });
return { deleted: true };
}
}
function toData(dto: UpdateOvertimeDto) {
return {
...(dto.productId !== undefined && { productId: dto.productId || null }),
...(dto.projectId !== undefined && { projectId: dto.projectId || null }),
...(dto.versionId !== undefined && { versionId: dto.versionId || null }),
...(dto.person !== undefined && { userId: dto.person }),
...(dto.reasonId !== undefined && { reason: dto.remark ? `${dto.reasonId}:${dto.remark}` : dto.reasonId }),
...(dto.startTime !== undefined && { startAt: new Date(dto.startTime) }),
...(dto.endTime !== undefined && { endAt: new Date(dto.endTime) }),
...(dto.duration !== undefined && { hours: dto.duration ?? 0 }),
};
}

View File

@@ -0,0 +1,24 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
export class CreateTaskCategoryDto {
@IsString()
name!: string;
@IsString()
group!: string;
@IsString()
@IsOptional()
code?: string;
@IsString()
@IsOptional()
color?: string;
@IsOptional()
sortOrder?: number;
@IsBoolean()
@IsOptional()
isSystem?: boolean;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTaskCategoryDto } from './create-task-category.dto';
export class UpdateTaskCategoryDto extends PartialType(CreateTaskCategoryDto) {}

View File

@@ -0,0 +1,29 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { CreateTaskCategoryDto } from './dto/create-task-category.dto';
import { UpdateTaskCategoryDto } from './dto/update-task-category.dto';
import { TaskCategoryService } from './task-category.service';
@Controller('task-categories')
export class TaskCategoryController {
constructor(private readonly taskCategoryService: TaskCategoryService) {}
@Get()
findAll() {
return this.taskCategoryService.findAll();
}
@Post()
create(@Body() dto: CreateTaskCategoryDto) {
return this.taskCategoryService.create(dto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateTaskCategoryDto) {
return this.taskCategoryService.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.taskCategoryService.remove(id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TaskCategoryController } from './task-category.controller';
import { TaskCategoryService } from './task-category.service';
@Module({
controllers: [TaskCategoryController],
providers: [TaskCategoryService],
exports: [TaskCategoryService],
})
export class TaskCategoryModule {}

View File

@@ -0,0 +1,41 @@
import { TaskCategoryService } from './task-category.service';
describe('TaskCategoryService domain writes', () => {
const makeService = () => {
const prisma = {
taskCategory: {
create: jest.fn(),
delete: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
};
return { prisma, service: new TaskCategoryService(prisma as any) };
};
it('creates task categories with uniqueness scoped by group and name', async () => {
const { prisma, service } = makeService();
prisma.taskCategory.create.mockResolvedValue({ id: 'cat-1', name: '前端开发', group: 'development' });
await service.create({ name: '前端开发', group: 'development', code: 'frontend_development' });
expect(prisma.taskCategory.create).toHaveBeenCalledWith({
data: expect.objectContaining({
name: '前端开发',
group: 'development',
code: 'frontend_development',
isSystem: false,
}),
});
});
it('does not delete system categories', async () => {
const { prisma, service } = makeService();
prisma.taskCategory.findMany.mockResolvedValue([{ id: 'cat-system', isSystem: true }]);
const result = await service.remove('cat-system');
expect(result).toEqual({ deleted: false });
expect(prisma.taskCategory.delete).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateTaskCategoryDto } from './dto/create-task-category.dto';
import { UpdateTaskCategoryDto } from './dto/update-task-category.dto';
@Injectable()
export class TaskCategoryService {
constructor(private readonly prisma: PrismaService) {}
findAll() {
return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] });
}
create(dto: CreateTaskCategoryDto) {
return this.prisma.taskCategory.create({
data: {
name: dto.name,
group: dto.group,
code: dto.code,
isSystem: dto.isSystem ?? false,
},
});
}
update(id: string, dto: UpdateTaskCategoryDto) {
return this.prisma.taskCategory.update({
where: { id },
data: {
...(dto.name !== undefined && { name: dto.name }),
...(dto.group !== undefined && { group: dto.group }),
...(dto.code !== undefined && { code: dto.code }),
...(dto.isSystem !== undefined && { isSystem: dto.isSystem }),
},
});
}
async remove(id: string) {
const [target] = await this.prisma.taskCategory.findMany({ where: { id }, take: 1 });
if (!target || target.isSystem) return { deleted: false };
await this.prisma.taskCategory.delete({ where: { id } });
return { deleted: true };
}
}

View File

@@ -0,0 +1,35 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateTaskWorklogDto {
@IsString()
taskId!: string;
@IsString()
userId!: string;
@IsString()
date!: string;
@IsOptional()
hours?: number;
@IsString()
@IsOptional()
workContent?: string;
@IsString()
@IsOptional()
sourceType?: string;
@IsString()
@IsOptional()
versionId?: string;
@IsString()
@IsOptional()
productId?: string;
@IsString()
@IsOptional()
projectId?: string;
}

View File

@@ -0,0 +1,23 @@
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
import { CreateTaskWorklogDto } from './dto/create-task-worklog.dto';
import { TaskWorklogService } from './task-worklog.service';
@Controller('task-worklogs')
export class TaskWorklogController {
constructor(private readonly taskWorklogService: TaskWorklogService) {}
@Get()
findAll() {
return this.taskWorklogService.findAll();
}
@Post()
create(@Body() dto: CreateTaskWorklogDto) {
return this.taskWorklogService.create(dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.taskWorklogService.remove(id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { WorkActivityModule } from '../work-activity/work-activity.module';
import { TaskWorklogController } from './task-worklog.controller';
import { TaskWorklogService } from './task-worklog.service';
@Module({
imports: [WorkActivityModule],
controllers: [TaskWorklogController],
providers: [TaskWorklogService],
exports: [TaskWorklogService],
})
export class TaskWorklogModule {}

View File

@@ -0,0 +1,36 @@
import { TaskWorklogService } from './task-worklog.service';
describe('TaskWorklogService append writes', () => {
it('appends task worklogs as relational evidence rows', async () => {
const prisma = {
taskWorklog: {
create: jest.fn().mockResolvedValue({ id: 'wl-1' }),
deleteMany: jest.fn(),
findMany: jest.fn(),
},
};
const service = new TaskWorklogService(prisma as any);
await service.create({
taskId: 'task-1',
userId: 'member-1',
date: '2026-07-08',
hours: 2,
workContent: '补充日报',
versionId: 'version-1',
});
expect(prisma.taskWorklog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
sourceType: 'dev_task',
sourceId: 'task-1',
sourceVersionId: 'version-1',
versionId: 'version-1',
userId: 'member-1',
workDate: new Date('2026-07-08T00:00:00.000Z'),
hours: 2,
content: '补充日报',
}),
});
});
});

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { WorkActivityService } from '../work-activity/work-activity.service';
import { CreateTaskWorklogDto } from './dto/create-task-worklog.dto';
@Injectable()
export class TaskWorklogService {
constructor(
private readonly prisma: PrismaService,
private readonly workActivity?: WorkActivityService,
) {}
async create(dto: CreateTaskWorklogDto) {
const item = await this.prisma.taskWorklog.create({
data: {
versionId: dto.versionId ?? null,
productId: dto.productId ?? null,
projectId: dto.projectId ?? null,
userId: dto.userId,
sourceType: dto.sourceType ?? 'dev_task',
sourceId: dto.taskId,
sourceVersionId: dto.versionId ?? null,
workDate: parseDateOnly(dto.date),
hours: dto.hours ?? 0,
content: dto.workContent ?? '',
},
});
if (dto.versionId) await this.workActivity?.markXiaobaoSummaryDirty(dto.versionId);
return item;
}
findAll() {
return this.prisma.taskWorklog.findMany({ orderBy: { createdAt: 'desc' } });
}
async remove(id: string) {
await this.prisma.taskWorklog.deleteMany({ where: { id } });
return { deleted: true };
}
}
function parseDateOnly(value: string): Date {
return new Date(`${value.slice(0, 10)}T00:00:00.000Z`);
}

View File

@@ -0,0 +1,22 @@
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
import { WorkActivityRecordInput, WorkActivityService } from './work-activity.service';
@Controller('work-activities')
export class WorkActivityController {
constructor(private readonly workActivityService: WorkActivityService) {}
@Get()
findAll() {
return this.workActivityService.findAll();
}
@Post()
create(@Body() dto: WorkActivityRecordInput) {
return this.workActivityService.record(dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.workActivityService.remove(id);
}
}

View File

@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { WorkActivityController } from './work-activity.controller';
import { WorkActivityService } from './work-activity.service';
@Module({
controllers: [WorkActivityController],
providers: [WorkActivityService],
exports: [WorkActivityService],
})

View File

@@ -22,6 +22,10 @@ export interface WorkActivityRecordInput {
export class WorkActivityService {
constructor(private readonly prisma: PrismaService) {}
findAll() {
return this.prisma.workActivity.findMany({ orderBy: { occurredAt: 'desc' } });
}
async record(input: WorkActivityRecordInput) {
const versionId = input.versionId ?? input.sourceVersionId ?? null;
const metadata = {
@@ -54,6 +58,11 @@ export class WorkActivityService {
return activity;
}
async remove(id: string) {
await this.prisma.workActivity.deleteMany({ where: { id } });
return { deleted: true };
}
async markXiaobaoSummaryDirty(versionId: string) {
const riskSignature = `dirty:${versionId}`;
await this.prisma.xiaobaoRiskSummary.upsert({

View File

@@ -2,10 +2,14 @@ import { api } from './api';
import type { Bug, BugSeverity, BugStatus } from './bug';
import type { DevTask, DevTaskStatus, Reference } from './dev-task';
import type { Priority } from './derive';
import type { Member } from './members';
import type { OvertimeRecord } from './overtime';
import type { Requirement, RequirementStatus, SourceType } from './requirement';
import type { TaskCategory } from './task-category';
import type { TaskWorklog } from './task-worklog';
import type { TestCase, TestCaseStatus } from './test-case';
import type { VersionPlan, VersionPlanLog, VersionPlanRequirementCoverage } from './version-plan';
import type { WorkActivity, WorkActivityCategory, WorkActivitySourceType } from './work-activity';
import type { WorkActivity, WorkActivityCategory, WorkActivityDraft, WorkActivitySourceType } from './work-activity';
export interface RootProject {
id: string;
@@ -254,6 +258,51 @@ interface DomainWorkActivityRow {
occurredAt?: string | Date | null;
}
interface DomainMemberRow {
id: string;
name: string;
username?: string | null;
departmentId?: string | null;
roleId?: string | null;
phone?: string | null;
email?: string | null;
password?: string | null;
isSystem?: boolean | null;
createdAt?: string | Date | null;
}
interface DomainTaskCategoryRow {
id: string;
name: string;
code?: string | null;
group: string;
isSystem?: boolean | null;
createdAt?: string | Date | null;
}
interface DomainTaskWorklogRow {
id: string;
userId?: string | null;
sourceId: string;
workDate?: string | Date | null;
hours?: number | null;
content?: string | null;
createdAt?: string | Date | null;
}
interface DomainOvertimeRow {
id: string;
productId?: string | null;
projectId?: string | null;
versionId?: string | null;
userId?: string | null;
reason?: string | null;
startAt?: string | Date | null;
endAt?: string | Date | null;
hours?: number | null;
createdAt?: string | Date | null;
}
interface DomainMutationResponse<T> {
item: T;
activities?: DomainWorkActivityRow[];
@@ -436,6 +485,83 @@ export async function deleteBugByVersionId(versionId: string, bugId: string): Pr
await api.delete(`/versions/${versionId}/bugs/${bugId}`);
}
export async function listMembersDomain(): Promise<Member[]> {
const rows = await api.get<DomainMemberRow[]>('/members');
return rows.map(normalizeMember);
}
export async function createMemberDomain(data: Partial<Member>, _existing?: Member[]): Promise<Member> {
return normalizeMember(await api.post<DomainMemberRow>('/members', toMemberPayload(data)));
}
export async function updateMemberDomain(memberId: string, data: Partial<Member>): Promise<Member> {
return normalizeMember(await api.patch<DomainMemberRow>(`/members/${memberId}`, toMemberPayload(data)));
}
export async function deleteMemberDomain(memberId: string): Promise<void> {
await api.delete(`/members/${memberId}`);
}
export async function listTaskCategoriesDomain(): Promise<TaskCategory[]> {
const rows = await api.get<DomainTaskCategoryRow[]>('/task-categories');
return rows.map((row, index) => normalizeTaskCategory(row, index));
}
export async function createTaskCategoryDomain(data: Partial<TaskCategory>): Promise<TaskCategory> {
return normalizeTaskCategory(await api.post<DomainTaskCategoryRow>('/task-categories', toTaskCategoryPayload(data)), 0);
}
export async function updateTaskCategoryDomain(categoryId: string, data: Partial<TaskCategory>): Promise<TaskCategory> {
return normalizeTaskCategory(await api.patch<DomainTaskCategoryRow>(`/task-categories/${categoryId}`, toTaskCategoryPayload(data)), 0);
}
export async function deleteTaskCategoryDomain(categoryId: string): Promise<void> {
await api.delete(`/task-categories/${categoryId}`);
}
export async function listTaskWorklogsDomain(): Promise<TaskWorklog[]> {
const rows = await api.get<DomainTaskWorklogRow[]>('/task-worklogs');
return rows.map(normalizeTaskWorklog);
}
export async function createTaskWorklogDomain(data: TaskWorklog): Promise<TaskWorklog> {
return normalizeTaskWorklog(await api.post<DomainTaskWorklogRow>('/task-worklogs', toTaskWorklogPayload(data)));
}
export async function deleteTaskWorklogDomain(worklogId: string): Promise<void> {
await api.delete(`/task-worklogs/${worklogId}`);
}
export async function listOvertimeRecordsDomain(): Promise<OvertimeRecord[]> {
const rows = await api.get<DomainOvertimeRow[]>('/overtime');
return rows.map(normalizeOvertimeRecord);
}
export async function createOvertimeRecordDomain(data: OvertimeRecord): Promise<OvertimeRecord> {
return normalizeOvertimeRecord(await api.post<DomainOvertimeRow>('/overtime', toOvertimePayload(data)));
}
export async function updateOvertimeRecordDomain(recordId: string, data: Partial<OvertimeRecord>): Promise<void> {
await api.patch(`/overtime/${recordId}`, toOvertimePayload(data));
}
export async function deleteOvertimeRecordDomain(recordId: string): Promise<void> {
await api.delete(`/overtime/${recordId}`);
}
export async function listWorkActivitiesDomain(): Promise<WorkActivity[]> {
const rows = await api.get<DomainWorkActivityRow[]>('/work-activities');
return rows.map(normalizeWorkActivity);
}
export async function createWorkActivityDomain(data: WorkActivity | WorkActivityDraft): Promise<WorkActivity> {
return normalizeWorkActivity(await api.post<DomainWorkActivityRow>('/work-activities', toWorkActivityPayload(data)));
}
export async function deleteWorkActivityDomain(activityId: string): Promise<void> {
await api.delete(`/work-activities/${activityId}`);
}
function normalizeProductRoot(product: RootProduct): RootProduct {
return {
...product,
@@ -753,6 +879,126 @@ function toBugSeverity(value: string | null | undefined): BugSeverity {
return 'minor';
}
function toMemberPayload(data: Partial<Member>) {
return {
...(data.id !== undefined && { id: data.id }),
...(data.name !== undefined && { name: data.name }),
...(data.username !== undefined && { username: data.username }),
...(data.departmentId !== undefined && { departmentId: data.departmentId }),
...(data.roleId !== undefined && { roleId: data.roleId }),
...(data.phone !== undefined && { phone: data.phone }),
...(data.email !== undefined && { email: data.email }),
...(data.password !== undefined && { password: data.password }),
...(data.isSystem !== undefined && { isSystem: data.isSystem }),
};
}
function normalizeMember(row: DomainMemberRow): Member {
return {
id: row.id,
name: row.name,
username: row.username ?? undefined,
departmentId: row.departmentId ?? '',
roleId: row.roleId ?? 'member',
phone: row.phone ?? '',
email: row.email ?? '',
password: row.password ?? '',
createdAt: isoString(row.createdAt).slice(0, 10),
isSystem: Boolean(row.isSystem),
};
}
function toTaskCategoryPayload(data: Partial<TaskCategory>) {
return {
...(data.name !== undefined && { name: data.name }),
...(data.group !== undefined && { group: data.group }),
...(data.code !== undefined && { code: data.code }),
...(data.isSystem !== undefined && { isSystem: data.isSystem }),
};
}
function normalizeTaskCategory(row: DomainTaskCategoryRow, index: number): TaskCategory {
return {
id: row.id,
code: row.code ?? row.name,
name: row.name,
group: toCategoryGroup(row.group),
sortOrder: index + 1,
isSystem: Boolean(row.isSystem),
};
}
function toCategoryGroup(value: string): TaskCategory['group'] {
if (value === 'development' || value === 'testing' || value === 'implementation') return value;
return 'other';
}
function toTaskWorklogPayload(data: TaskWorklog) {
return {
taskId: data.taskId,
userId: data.userId,
date: data.date,
hours: data.hours,
workContent: data.workContent,
};
}
function normalizeTaskWorklog(row: DomainTaskWorklogRow): TaskWorklog {
return {
id: row.id,
taskId: row.sourceId,
userId: row.userId ?? '',
date: isoString(row.workDate).slice(0, 10),
hours: row.hours ?? 0,
workContent: row.content ?? '',
createdAt: isoString(row.createdAt),
};
}
function toOvertimePayload(data: Partial<OvertimeRecord>) {
return {
...(data.productId !== undefined && { productId: data.productId }),
...(data.projectId !== undefined && { projectId: data.projectId }),
...(data.versionId !== undefined && { versionId: data.versionId }),
...(data.person !== undefined && { person: data.person }),
...(data.startTime !== undefined && { startTime: data.startTime }),
...(data.endTime !== undefined && { endTime: data.endTime }),
...(data.duration !== undefined && { duration: data.duration }),
...(data.reasonId !== undefined && { reasonId: data.reasonId }),
...(data.remark !== undefined && { remark: data.remark }),
};
}
function normalizeOvertimeRecord(row: DomainOvertimeRow): OvertimeRecord {
const [reasonId, ...remarkParts] = (row.reason ?? '').split(':');
return {
id: row.id,
projectId: row.projectId ?? '',
versionId: row.versionId ?? undefined,
person: row.userId ?? '',
startTime: isoString(row.startAt),
endTime: isoString(row.endAt),
duration: row.hours ?? 0,
reasonId,
remark: remarkParts.join(':') || undefined,
createdAt: isoString(row.createdAt).slice(0, 10),
};
}
function toWorkActivityPayload(data: WorkActivity | WorkActivityDraft) {
return {
actorId: data.actorId,
sourceType: data.sourceType,
sourceId: data.sourceId,
action: data.action,
title: data.title,
category: data.category,
summary: data.summary,
metadata: data.metadata,
occurredAt: data.occurredAt,
};
}
function normalizeMutation<Row, Item>(
response: DomainMutationResponse<Row>,
mapper: (row: Row) => Item,

View File

@@ -5,6 +5,7 @@ import { hasPermission } from './permissions';
export interface OvertimeRecord {
id: string;
productId?: string;
projectId: string;
versionId?: string;
requirementId?: string;

View File

@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
function store(name: string) {
return readFileSync(join(process.cwd(), `stores/${name}`), 'utf8');
}
function storeMethodBody(text: string, name: string) {
const implementationStart = text.indexOf('export const');
assert.notEqual(implementationStart, -1, 'missing store implementation');
const start = text.indexOf(` ${name}:`, implementationStart);
assert.notEqual(start, -1, `missing store method ${name}`);
let depth = 0;
let sawFirstBrace = false;
for (let i = start; i < text.length; i += 1) {
const char = text[i];
if (char === '{') {
depth += 1;
sawFirstBrace = true;
}
if (char === '}') {
depth -= 1;
if (sawFirstBrace && depth === 0) return text.slice(start, i + 1);
}
}
throw new Error(`could not extract store method ${name}`);
}
test('member and dictionary stores use domain APIs as primary writes', () => {
const members = store('useMemberStore.ts');
const categories = store('useTaskCategoryStore.ts');
assert.match(members, /from '@\/lib\/domain-api'/);
assert.match(storeMethodBody(members, 'createMember'), /createMemberDomain\(member,/);
assert.match(storeMethodBody(members, 'updateMember'), /updateMemberDomain\(id,/);
assert.match(storeMethodBody(members, 'deleteMember'), /deleteMemberDomain\(id\)/);
assert.match(categories, /from '@\/lib\/domain-api'/);
assert.match(storeMethodBody(categories, 'addCategory'), /createTaskCategoryDomain\(item\)/);
assert.match(storeMethodBody(categories, 'updateCategory'), /updateTaskCategoryDomain\(id,/);
assert.match(storeMethodBody(categories, 'deleteCategory'), /deleteTaskCategoryDomain\(id\)/);
});
test('evidence stores use append-style domain APIs as primary writes', () => {
const worklogs = store('useTaskWorklogStore.ts');
const overtime = store('useOvertimeStore.ts');
const activities = store('useWorkActivityStore.ts');
assert.match(storeMethodBody(worklogs, 'addWorklog'), /createTaskWorklogDomain\(item\)/);
assert.match(storeMethodBody(overtime, 'createRecord'), /createOvertimeRecordDomain\(record\)/);
assert.match(storeMethodBody(activities, 'addActivity'), /createWorkActivityDomain\(item\)/);
assert.doesNotMatch(storeMethodBody(worklogs, 'addWorklog'), /saveServerData\('task-worklogs'/);
assert.doesNotMatch(storeMethodBody(overtime, 'createRecord'), /saveServerData\('overtime'/);
assert.doesNotMatch(storeMethodBody(activities, 'addActivity'), /saveServerData\('work-activities'/);
});

View File

@@ -9,6 +9,12 @@ import {
ensureSystemAdminMember,
sanitizeSystemAdminPatch,
} from '@/lib/member-system';
import {
createMemberDomain,
deleteMemberDomain,
listMembersDomain,
updateMemberDomain,
} from '@/lib/domain-api';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { DEFAULT_ROLE_PERMISSIONS, DEFAULT_ROLE_PRESETS } from '@/lib/permissions';
import { mergePresetRolePermissions } from '@/lib/role-permission-migration';
@@ -72,7 +78,10 @@ export const useMemberStore = create<MemberState>((set, get) => ({
fetchMembers: async (options) => {
if (!options?.force && get().loaded && Date.now() - lastMembersFetchAt < SERVER_DATA_CACHE_MS) return;
const cached = await loadStored();
const [cached, domainMembers] = await Promise.all([
loadStored(),
listMembersDomain().catch(() => null),
]);
if (!options?.force && get().loaded && Date.now() - lastMembersFetchAt < SERVER_DATA_CACHE_MS) return;
lastMembersFetchAt = Date.now();
if (cached) {
@@ -93,7 +102,8 @@ export const useMemberStore = create<MemberState>((set, get) => ({
permissionMerged.changed ||
roles.length !== rolesWithNewDefaults.length ||
roles.some((role, index) => JSON.stringify(role) !== JSON.stringify(rolesWithNewDefaults[index]));
const ensured = ensureSystemAdminMember(cached.members);
const sourceMembers = domainMembers && domainMembers.length > 0 ? domainMembers : cached.members;
const ensured = ensureSystemAdminMember(sourceMembers);
const usernameEnsured = ensureMemberUsernames(ensured.members);
const next = {
departments: cached.departments,
@@ -104,7 +114,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
set({ ...next, loaded: true });
if (ensured.changed || usernameEnsured.changed || rolesChanged) void saveStored(next).catch(() => {});
} else {
set({ loaded: true });
set({ members: domainMembers && domainMembers.length > 0 ? domainMembers : get().members, loaded: true });
}
},
@@ -166,7 +176,16 @@ export const useMemberStore = create<MemberState>((set, get) => ({
const members = [...previous, member];
set({ members });
scheduleSaveWithOptimisticRollback({
save: () => saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }),
save: async () => {
try {
const created = await createMemberDomain(member, previous);
set({
members: get().members.map((item) => (item.id === member.id ? { ...member, ...created } : item)),
});
} catch {
await saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
}
},
expected: members,
getCurrent: () => get().members,
rollback: () => set({ members: previous }),
@@ -179,10 +198,14 @@ export const useMemberStore = create<MemberState>((set, get) => ({
const ensured = ensureSystemAdminMember(updatedMembers);
set({ members: ensured.members });
try {
await saveStored({ departments: get().departments, members: ensured.members, roles: get().roles, passwordRule: get().passwordRule });
await updateMemberDomain(id, safeData);
} catch (error) {
set({ members: previousMembers });
throw error;
try {
await saveStored({ departments: get().departments, members: ensured.members, roles: get().roles, passwordRule: get().passwordRule });
} catch {
set({ members: previousMembers });
throw error;
}
}
},
deleteMember: (id) => {
@@ -191,7 +214,13 @@ export const useMemberStore = create<MemberState>((set, get) => ({
const members = previous.filter((m) => m.id !== id);
set({ members });
scheduleSaveWithOptimisticRollback({
save: () => saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }),
save: async () => {
try {
await deleteMemberDomain(id);
} catch {
await saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
}
},
expected: members,
getCurrent: () => get().members,
rollback: () => set({ members: previous }),

View File

@@ -3,6 +3,12 @@ import { create } from 'zustand';
import type { OvertimeRecord } from '@/lib/overtime';
import type { DictItem } from '@/lib/requirement';
import { calcDuration } from '@/lib/overtime';
import {
createOvertimeRecordDomain,
deleteOvertimeRecordDomain,
listOvertimeRecordsDomain,
updateOvertimeRecordDomain,
} from '@/lib/domain-api';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
@@ -52,10 +58,17 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
fetchRecords: async (options) => {
if (!options?.force && get().loaded && Date.now() - lastOvertimeFetchAt < SERVER_DATA_CACHE_MS) return;
const cached = await loadStored();
const [cached, domainRecords] = await Promise.all([
loadStored(),
listOvertimeRecordsDomain().catch(() => null),
]);
if (!options?.force && get().loaded && Date.now() - lastOvertimeFetchAt < SERVER_DATA_CACHE_MS) return;
lastOvertimeFetchAt = Date.now();
set({ records: cached?.records ?? MOCK_RECORDS, reasons: cached?.reasons ?? PRESET_REASONS, loaded: true });
set({
records: domainRecords && domainRecords.length > 0 ? domainRecords : cached?.records ?? MOCK_RECORDS,
reasons: cached?.reasons ?? PRESET_REASONS,
loaded: true,
});
},
createRecord: (data) => {
@@ -70,7 +83,17 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
const records = [...previous, record];
set({ records, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('overtime', { records, reasons: get().reasons }),
save: async () => {
try {
const created = await createOvertimeRecordDomain(record);
set({
records: get().records.map((item) => (item.id === record.id ? { ...record, ...created } : item)),
loaded: true,
});
} catch {
await saveOvertimeFallback(records, get().reasons);
}
},
expected: records,
getCurrent: () => get().records,
rollback: () => set({ records: previous, loaded: true }),
@@ -89,7 +112,13 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
});
set({ records, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('overtime', { records, reasons: get().reasons }),
save: async () => {
try {
await updateOvertimeRecordDomain(id, records.find((record) => record.id === id) ?? {});
} catch {
await saveOvertimeFallback(records, get().reasons);
}
},
expected: records,
getCurrent: () => get().records,
rollback: () => set({ records: previous, loaded: true }),
@@ -101,7 +130,13 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
const records = previous.filter((r) => r.id !== id);
set({ records, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('overtime', { records, reasons: get().reasons }),
save: async () => {
try {
await deleteOvertimeRecordDomain(id);
} catch {
await saveOvertimeFallback(records, get().reasons);
}
},
expected: records,
getCurrent: () => get().records,
rollback: () => set({ records: previous, loaded: true }),
@@ -145,3 +180,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
});
},
}));
async function saveOvertimeFallback(records: OvertimeRecord[], reasons: DictItem[]) {
await saveServerData('overtime', { records, reasons });
}

View File

@@ -2,6 +2,12 @@
import { create } from 'zustand';
import type { TaskCategory, CategoryGroup } from '@/lib/task-category';
import { PRESET_CATEGORIES, normalizeTaskCategories, resolveAiTaskCategoryByName } from '@/lib/task-category';
import {
createTaskCategoryDomain,
deleteTaskCategoryDomain,
listTaskCategoriesDomain,
updateTaskCategoryDomain,
} from '@/lib/domain-api';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
@@ -30,7 +36,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
fetchCategories: async (options) => {
if (!options?.force && get().loaded && Date.now() - lastCategoriesFetchAt < SERVER_DATA_CACHE_MS) return;
const cached = await loadStored();
const cached = await listTaskCategoriesDomain().catch(loadStored);
if (!options?.force && get().loaded && Date.now() - lastCategoriesFetchAt < SERVER_DATA_CACHE_MS) return;
lastCategoriesFetchAt = Date.now();
set({ categories: cached ? normalizeTaskCategories(cached) : PRESET_CATEGORIES, loaded: true });
@@ -50,7 +56,17 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
const updated = [...list, item];
set({ categories: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('task-categories', updated),
save: async () => {
try {
const created = await createTaskCategoryDomain(item);
set({
categories: get().categories.map((category) => (category.id === item.id ? { ...item, ...created } : category)),
loaded: true,
});
} catch {
await saveTaskCategoriesFallback(updated);
}
},
expected: updated,
getCurrent: () => get().categories,
rollback: () => set({ categories: list, loaded: true }),
@@ -63,7 +79,13 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
if (result.created) {
set({ categories: result.categories, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('task-categories', result.categories),
save: async () => {
try {
await createTaskCategoryDomain(result.category);
} catch {
await saveTaskCategoriesFallback(result.categories);
}
},
expected: result.categories,
getCurrent: () => get().categories,
rollback: () => set({ categories: previous, loaded: true }),
@@ -77,7 +99,13 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
const updated = previous.map((c) => (c.id === id ? { ...c, ...data } : c));
set({ categories: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('task-categories', updated),
save: async () => {
try {
await updateTaskCategoryDomain(id, data);
} catch {
await saveTaskCategoriesFallback(updated);
}
},
expected: updated,
getCurrent: () => get().categories,
rollback: () => set({ categories: previous, loaded: true }),
@@ -91,7 +119,13 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
const updated = previous.filter((c) => c.id !== id);
set({ categories: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('task-categories', updated),
save: async () => {
try {
await deleteTaskCategoryDomain(id);
} catch {
await saveTaskCategoriesFallback(updated);
}
},
expected: updated,
getCurrent: () => get().categories,
rollback: () => set({ categories: previous, loaded: true }),
@@ -99,3 +133,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
return true;
},
}));
async function saveTaskCategoriesFallback(categories: TaskCategory[]) {
await saveServerData('task-categories', categories);
}

View File

@@ -1,6 +1,11 @@
'use client';
import { create } from 'zustand';
import type { TaskWorklog } from '@/lib/task-worklog';
import {
createTaskWorklogDomain,
deleteTaskWorklogDomain,
listTaskWorklogsDomain,
} from '@/lib/domain-api';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
@@ -28,7 +33,7 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
fetchWorklogs: async (options) => {
if (!options?.force && get().loaded && Date.now() - lastWorklogsFetchAt < SERVER_DATA_CACHE_MS) return;
const cached = await loadStored();
const cached = await listTaskWorklogsDomain().catch(loadStored);
if (!options?.force && get().loaded && Date.now() - lastWorklogsFetchAt < SERVER_DATA_CACHE_MS) return;
lastWorklogsFetchAt = Date.now();
set({ worklogs: cached ?? [], loaded: true });
@@ -44,7 +49,17 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
const updated = [...previous, item];
set({ worklogs: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('task-worklogs', updated),
save: async () => {
try {
const created = await createTaskWorklogDomain(item);
set({
worklogs: get().worklogs.map((worklog) => (worklog.id === item.id ? { ...item, ...created } : worklog)),
loaded: true,
});
} catch {
await saveTaskWorklogsFallback(updated);
}
},
expected: updated,
getCurrent: () => get().worklogs,
rollback: () => set({ worklogs: previous, loaded: true }),
@@ -56,7 +71,13 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
const updated = previous.filter((w) => w.id !== id);
set({ worklogs: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('task-worklogs', updated),
save: async () => {
try {
await deleteTaskWorklogDomain(id);
} catch {
await saveTaskWorklogsFallback(updated);
}
},
expected: updated,
getCurrent: () => get().worklogs,
rollback: () => set({ worklogs: previous, loaded: true }),
@@ -69,3 +90,7 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
.reduce((sum, w) => sum + w.hours, 0);
},
}));
async function saveTaskWorklogsFallback(worklogs: TaskWorklog[]) {
await saveServerData('task-worklogs', worklogs);
}

View File

@@ -3,6 +3,12 @@ import { create } from 'zustand';
import { formatLocalDate } from '@/lib/format';
import { mergeWorkActivities, type WorkActivity, type WorkActivityDraft } from '@/lib/work-activity';
import { makeProgressNoteActivity, type ProgressNoteActivityInput } from '@/lib/work-activity-factory';
import {
createWorkActivityDomain,
deleteWorkActivityDomain,
listWorkActivitiesDomain,
} from '@/lib/domain-api';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
interface WorkActivityState {
@@ -52,13 +58,14 @@ export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
fetchActivities: async (options) => {
if (!options?.force && get().loaded && Date.now() - lastActivitiesFetchAt < SERVER_DATA_CACHE_MS) return;
const cached = await loadStored();
const cached = await listWorkActivitiesDomain().catch(loadStored);
if (!options?.force && get().loaded && Date.now() - lastActivitiesFetchAt < SERVER_DATA_CACHE_MS) return;
lastActivitiesFetchAt = Date.now();
set({ activities: cached ?? [], loaded: true });
},
addActivity: (data) => {
const previous = get().activities;
const now = new Date();
const item: WorkActivity = {
...data,
@@ -66,9 +73,24 @@ export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
date: data.date ?? formatLocalDate(now),
occurredAt: data.occurredAt ?? now.toISOString(),
};
const updated = [...get().activities, item];
const updated = [...previous, item];
set({ activities: updated, loaded: true });
saveStored(updated, (activities) => set({ activities, loaded: true }));
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const created = await createWorkActivityDomain(item);
set({
activities: get().activities.map((activity) => (activity.id === item.id ? { ...item, ...created } : activity)),
loaded: true,
});
} catch {
await saveStored(updated, (activities) => set({ activities, loaded: true }));
}
},
expected: updated,
getCurrent: () => get().activities,
rollback: () => set({ activities: previous, loaded: true }),
});
return item;
},
@@ -77,8 +99,20 @@ export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
},
deleteActivity: (id) => {
const updated = get().activities.filter((activity) => activity.id !== id);
const previous = get().activities;
const updated = previous.filter((activity) => activity.id !== id);
set({ activities: updated, loaded: true });
saveStored(updated, (activities) => set({ activities, loaded: true }), { mergeRemote: false });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
await deleteWorkActivityDomain(id);
} catch {
await saveStored(updated, (activities) => set({ activities, loaded: true }), { mergeRemote: false });
}
},
expected: updated,
getCurrent: () => get().activities,
rollback: () => set({ activities: previous, loaded: true }),
});
},
}));