feat(v2.4): 完成领域主写迁移
This commit is contained in:
@@ -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");
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
34
apps/server/src/modules/member/dto/create-member.dto.ts
Normal file
34
apps/server/src/modules/member/dto/create-member.dto.ts
Normal 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;
|
||||
}
|
||||
4
apps/server/src/modules/member/dto/update-member.dto.ts
Normal file
4
apps/server/src/modules/member/dto/update-member.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateMemberDto } from './create-member.dto';
|
||||
|
||||
export class UpdateMemberDto extends PartialType(CreateMemberDto) {}
|
||||
29
apps/server/src/modules/member/member.controller.ts
Normal file
29
apps/server/src/modules/member/member.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/server/src/modules/member/member.module.ts
Normal file
10
apps/server/src/modules/member/member.module.ts
Normal 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 {}
|
||||
94
apps/server/src/modules/member/member.service.spec.ts
Normal file
94
apps/server/src/modules/member/member.service.spec.ts
Normal 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 }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
95
apps/server/src/modules/member/member.service.ts
Normal file
95
apps/server/src/modules/member/member.service.ts
Normal 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);
|
||||
}
|
||||
34
apps/server/src/modules/overtime/dto/create-overtime.dto.ts
Normal file
34
apps/server/src/modules/overtime/dto/create-overtime.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateOvertimeDto } from './create-overtime.dto';
|
||||
|
||||
export class UpdateOvertimeDto extends PartialType(CreateOvertimeDto) {}
|
||||
29
apps/server/src/modules/overtime/overtime.controller.ts
Normal file
29
apps/server/src/modules/overtime/overtime.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/server/src/modules/overtime/overtime.module.ts
Normal file
12
apps/server/src/modules/overtime/overtime.module.ts
Normal 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 {}
|
||||
37
apps/server/src/modules/overtime/overtime.service.spec.ts
Normal file
37
apps/server/src/modules/overtime/overtime.service.spec.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
56
apps/server/src/modules/overtime/overtime.service.ts
Normal file
56
apps/server/src/modules/overtime/overtime.service.ts
Normal 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 }),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateTaskCategoryDto } from './create-task-category.dto';
|
||||
|
||||
export class UpdateTaskCategoryDto extends PartialType(CreateTaskCategoryDto) {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
12
apps/server/src/modules/task-worklog/task-worklog.module.ts
Normal file
12
apps/server/src/modules/task-worklog/task-worklog.module.ts
Normal 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 {}
|
||||
@@ -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: '补充日报',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
44
apps/server/src/modules/task-worklog/task-worklog.service.ts
Normal file
44
apps/server/src/modules/task-worklog/task-worklog.service.ts
Normal 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`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { hasPermission } from './permissions';
|
||||
|
||||
export interface OvertimeRecord {
|
||||
id: string;
|
||||
productId?: string;
|
||||
projectId: string;
|
||||
versionId?: string;
|
||||
requirementId?: string;
|
||||
|
||||
58
apps/web/lib/v245-domain-source.test.ts
Normal file
58
apps/web/lib/v245-domain-source.test.ts
Normal 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'/);
|
||||
});
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user