feat: restructure into XFlow monorepo with Nuxt 4, Modular Onion Monolith, Android client, and Desktop CLI
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { IUseCase } from '../../core/application/IUseCase';
|
||||
import { Result } from '../../core/domain/Result';
|
||||
import { DomainError, ValidationError } from '../../core/domain/DomainError';
|
||||
import { IListRepository } from '../domain/IListRepository';
|
||||
import { NoteList } from '../domain/NoteList';
|
||||
import { ListItem } from '../domain/ListItem';
|
||||
import { AddListItemDto } from './dtos/ListDtos';
|
||||
|
||||
export class AddListItemUseCase implements IUseCase<AddListItemDto, ListItem> {
|
||||
constructor(private readonly listRepository: IListRepository) {}
|
||||
|
||||
public async execute(dto: AddListItemDto): Promise<Result<ListItem, DomainError>> {
|
||||
if (!dto.title || dto.title.trim().length === 0) {
|
||||
return Result.fail(new ValidationError('Item title is required'));
|
||||
}
|
||||
|
||||
if (!dto.ownerId) {
|
||||
return Result.fail(new ValidationError('Owner ID is required'));
|
||||
}
|
||||
|
||||
let targetList: NoteList | null = null;
|
||||
|
||||
if (dto.listId) {
|
||||
targetList = await this.listRepository.findListById(dto.listId);
|
||||
} else if (dto.listTitle) {
|
||||
targetList = await this.listRepository.findListByTitleAndOwner(dto.listTitle.trim(), dto.ownerId);
|
||||
}
|
||||
|
||||
// Если список не найден по названию (например, первая запись фильма), создаем список автоматически
|
||||
if (!targetList) {
|
||||
const listTitle = dto.listTitle?.trim() || 'Общий список';
|
||||
const category = dto.category || (listTitle.toLowerCase().includes('фильм') ? 'movies' : 'general');
|
||||
|
||||
targetList = new NoteList({
|
||||
id: `list-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`,
|
||||
title: listTitle,
|
||||
category: category,
|
||||
description: `Автоматически создан для ${listTitle}`,
|
||||
ownerId: dto.ownerId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
items: []
|
||||
});
|
||||
|
||||
await this.listRepository.saveList(targetList);
|
||||
}
|
||||
|
||||
const newItem = new ListItem({
|
||||
id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`,
|
||||
listId: targetList.id,
|
||||
title: dto.title.trim(),
|
||||
description: dto.description || '',
|
||||
isCompleted: false,
|
||||
status: targetList.category === 'movies' ? 'want_to_watch' : 'todo',
|
||||
priority: dto.priority || 'normal',
|
||||
metadata: dto.metadata || {},
|
||||
dueDate: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
await this.listRepository.saveItem(newItem);
|
||||
targetList.addItem(newItem);
|
||||
|
||||
return Result.ok(newItem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IUseCase } from '../../core/application/IUseCase';
|
||||
import { Result } from '../../core/domain/Result';
|
||||
import { DomainError, ValidationError } from '../../core/domain/DomainError';
|
||||
import { IListRepository } from '../domain/IListRepository';
|
||||
import { NoteList } from '../domain/NoteList';
|
||||
|
||||
export interface GetListsRequest {
|
||||
ownerId: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export class GetListsUseCase implements IUseCase<GetListsRequest, NoteList[]> {
|
||||
constructor(private readonly listRepository: IListRepository) {}
|
||||
|
||||
public async execute(req: GetListsRequest): Promise<Result<NoteList[], DomainError>> {
|
||||
if (!req.ownerId) {
|
||||
return Result.fail(new ValidationError('Owner ID is required'));
|
||||
}
|
||||
|
||||
const lists = await this.listRepository.findListsByOwner(req.ownerId, req.category);
|
||||
return Result.ok(lists);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { IUseCase } from '../../core/application/IUseCase';
|
||||
import { Result } from '../../core/domain/Result';
|
||||
import { DomainError, NotFoundError, ValidationError } from '../../core/domain/DomainError';
|
||||
import { IListRepository } from '../domain/IListRepository';
|
||||
import { ListItem } from '../domain/ListItem';
|
||||
import { ToggleListItemDto } from './dtos/ListDtos';
|
||||
|
||||
export class ToggleListItemUseCase implements IUseCase<ToggleListItemDto, ListItem> {
|
||||
constructor(private readonly listRepository: IListRepository) {}
|
||||
|
||||
public async execute(dto: ToggleListItemDto): Promise<Result<ListItem, DomainError>> {
|
||||
if (!dto.itemId) {
|
||||
return Result.fail(new ValidationError('Item ID is required'));
|
||||
}
|
||||
|
||||
const item = await this.listRepository.findItemById(dto.itemId);
|
||||
if (!item) {
|
||||
return Result.fail(new NotFoundError(`Item with ID ${dto.itemId} not found`));
|
||||
}
|
||||
|
||||
if (dto.status) {
|
||||
item.updateStatus(dto.status);
|
||||
} else {
|
||||
item.toggleCompleted();
|
||||
}
|
||||
|
||||
await this.listRepository.saveItem(item);
|
||||
return Result.ok(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface CreateListDto {
|
||||
title: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface AddListItemDto {
|
||||
listId?: string;
|
||||
listTitle?: string; // Если передано имя (например из голосового ввода Gemini или CLI "Фильмы")
|
||||
category?: string; // Категория по умолчанию, если список создается на лету
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: string;
|
||||
metadata?: Record<string, any>;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface ToggleListItemDto {
|
||||
itemId: string;
|
||||
status?: string;
|
||||
ownerId: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NoteList } from './NoteList';
|
||||
import { ListItem } from './ListItem';
|
||||
|
||||
export interface IListRepository {
|
||||
findListById(id: string): Promise<NoteList | null>;
|
||||
findListByTitleAndOwner(title: string, ownerId: string): Promise<NoteList | null>;
|
||||
findListsByOwner(ownerId: string, category?: string): Promise<NoteList[]>;
|
||||
saveList(list: NoteList): Promise<void>;
|
||||
deleteList(id: string, ownerId: string): Promise<boolean>;
|
||||
|
||||
findItemById(id: string): Promise<ListItem | null>;
|
||||
saveItem(item: ListItem): Promise<void>;
|
||||
deleteItem(id: string): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export interface ListItemProps {
|
||||
id: string;
|
||||
listId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
isCompleted: boolean;
|
||||
status: string; // 'todo' | 'in_progress' | 'completed' | 'watching' | 'watched'
|
||||
priority: string; // 'low' | 'normal' | 'high'
|
||||
metadata?: Record<string, any>;
|
||||
dueDate?: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class ListItem {
|
||||
constructor(private readonly props: ListItemProps) {}
|
||||
|
||||
public get id(): string { return this.props.id; }
|
||||
public get listId(): string { return this.props.listId; }
|
||||
public get title(): string { return this.props.title; }
|
||||
public get description(): string { return this.props.description || ''; }
|
||||
public get isCompleted(): boolean { return this.props.isCompleted; }
|
||||
public get status(): string { return this.props.status; }
|
||||
public get priority(): string { return this.props.priority; }
|
||||
public get metadata(): Record<string, any> | undefined { return this.props.metadata; }
|
||||
public get dueDate(): Date | null | undefined { return this.props.dueDate; }
|
||||
public get createdAt(): Date { return this.props.createdAt; }
|
||||
public get updatedAt(): Date { return this.props.updatedAt; }
|
||||
|
||||
public toggleCompleted(): void {
|
||||
this.props.isCompleted = !this.props.isCompleted;
|
||||
this.props.status = this.props.isCompleted ? 'completed' : 'todo';
|
||||
this.props.updatedAt = new Date();
|
||||
}
|
||||
|
||||
public updateStatus(status: string): void {
|
||||
this.props.status = status;
|
||||
this.props.isCompleted = (status === 'completed' || status === 'watched');
|
||||
this.props.updatedAt = new Date();
|
||||
}
|
||||
|
||||
public toJSON(): ListItemProps {
|
||||
return { ...this.props };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ListItem } from './ListItem';
|
||||
|
||||
export interface NoteListProps {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string; // 'movies', 'shopping', 'tasks', 'general'
|
||||
description?: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
ownerId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
items?: ListItem[];
|
||||
}
|
||||
|
||||
export class NoteList {
|
||||
constructor(private readonly props: NoteListProps) {}
|
||||
|
||||
public get id(): string { return this.props.id; }
|
||||
public get title(): string { return this.props.title; }
|
||||
public get category(): string { return this.props.category; }
|
||||
public get description(): string { return this.props.description || ''; }
|
||||
public get color(): string | null | undefined { return this.props.color; }
|
||||
public get icon(): string | null | undefined { return this.props.icon; }
|
||||
public get ownerId(): string { return this.props.ownerId; }
|
||||
public get createdAt(): Date { return this.props.createdAt; }
|
||||
public get updatedAt(): Date { return this.props.updatedAt; }
|
||||
public get items(): ListItem[] { return this.props.items || []; }
|
||||
|
||||
public addItem(item: ListItem): void {
|
||||
if (!this.props.items) {
|
||||
this.props.items = [];
|
||||
}
|
||||
this.props.items.push(item);
|
||||
this.props.updatedAt = new Date();
|
||||
}
|
||||
|
||||
public toJSON(): NoteListProps {
|
||||
return {
|
||||
...this.props,
|
||||
items: this.props.items?.map(i => i.toJSON())
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PrismaListRepository } from './infrastructure/PrismaListRepository';
|
||||
import { AddListItemUseCase } from './application/AddListItemUseCase';
|
||||
import { GetListsUseCase } from './application/GetListsUseCase';
|
||||
import { ToggleListItemUseCase } from './application/ToggleListItemUseCase';
|
||||
|
||||
const listRepository = new PrismaListRepository();
|
||||
|
||||
export const addListItemUseCase = new AddListItemUseCase(listRepository);
|
||||
export const getListsUseCase = new GetListsUseCase(listRepository);
|
||||
export const toggleListItemUseCase = new ToggleListItemUseCase(listRepository);
|
||||
export { listRepository };
|
||||
@@ -0,0 +1,162 @@
|
||||
import { prisma } from '../../../utils/prisma';
|
||||
import { IListRepository } from '../domain/IListRepository';
|
||||
import { NoteList } from '../domain/NoteList';
|
||||
import { ListItem } from '../domain/ListItem';
|
||||
|
||||
export class PrismaListRepository implements IListRepository {
|
||||
public async findListById(id: string): Promise<NoteList | null> {
|
||||
const record = await (prisma as any).noteList.findUnique({
|
||||
where: { id },
|
||||
include: { items: true }
|
||||
});
|
||||
|
||||
if (!record) return null;
|
||||
return this.mapToDomain(record);
|
||||
}
|
||||
|
||||
public async findListByTitleAndOwner(title: string, ownerId: string): Promise<NoteList | null> {
|
||||
const record = await (prisma as any).noteList.findFirst({
|
||||
where: {
|
||||
title: { equals: title, mode: 'insensitive' },
|
||||
ownerId
|
||||
},
|
||||
include: { items: true }
|
||||
});
|
||||
|
||||
if (!record) return null;
|
||||
return this.mapToDomain(record);
|
||||
}
|
||||
|
||||
public async findListsByOwner(ownerId: string, category?: string): Promise<NoteList[]> {
|
||||
const where: any = { ownerId };
|
||||
if (category) {
|
||||
where.category = category;
|
||||
}
|
||||
|
||||
const records = await (prisma as any).noteList.findMany({
|
||||
where,
|
||||
include: { items: { orderBy: { createdAt: 'desc' } } },
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
});
|
||||
|
||||
return records.map((r: any) => this.mapToDomain(r));
|
||||
}
|
||||
|
||||
public async saveList(list: NoteList): Promise<void> {
|
||||
await (prisma as any).noteList.upsert({
|
||||
where: { id: list.id },
|
||||
create: {
|
||||
id: list.id,
|
||||
title: list.title,
|
||||
category: list.category,
|
||||
description: list.description,
|
||||
color: list.color,
|
||||
icon: list.icon,
|
||||
ownerId: list.ownerId,
|
||||
createdAt: list.createdAt,
|
||||
updatedAt: list.updatedAt
|
||||
},
|
||||
update: {
|
||||
title: list.title,
|
||||
category: list.category,
|
||||
description: list.description,
|
||||
color: list.color,
|
||||
icon: list.icon,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteList(id: string, ownerId: string): Promise<boolean> {
|
||||
const res = await (prisma as any).noteList.deleteMany({
|
||||
where: { id, ownerId }
|
||||
});
|
||||
return res.count > 0;
|
||||
}
|
||||
|
||||
public async findItemById(id: string): Promise<ListItem | null> {
|
||||
const record = await (prisma as any).listItem.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!record) return null;
|
||||
return new ListItem({
|
||||
id: record.id,
|
||||
listId: record.listId,
|
||||
title: record.title,
|
||||
description: record.description,
|
||||
isCompleted: record.isCompleted,
|
||||
status: record.status,
|
||||
priority: record.priority,
|
||||
metadata: record.metadata as any,
|
||||
dueDate: record.dueDate,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt
|
||||
});
|
||||
}
|
||||
|
||||
public async saveItem(item: ListItem): Promise<void> {
|
||||
await (prisma as any).listItem.upsert({
|
||||
where: { id: item.id },
|
||||
create: {
|
||||
id: item.id,
|
||||
listId: item.listId,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
isCompleted: item.isCompleted,
|
||||
status: item.status,
|
||||
priority: item.priority,
|
||||
metadata: item.metadata || {},
|
||||
dueDate: item.dueDate,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt
|
||||
},
|
||||
update: {
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
isCompleted: item.isCompleted,
|
||||
status: item.status,
|
||||
priority: item.priority,
|
||||
metadata: item.metadata || {},
|
||||
dueDate: item.dueDate,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteItem(id: string): Promise<boolean> {
|
||||
const res = await (prisma as any).listItem.delete({
|
||||
where: { id }
|
||||
});
|
||||
return !!res;
|
||||
}
|
||||
|
||||
private mapToDomain(record: any): NoteList {
|
||||
const items = (record.items || []).map((i: any) => new ListItem({
|
||||
id: i.id,
|
||||
listId: i.listId,
|
||||
title: i.title,
|
||||
description: i.description,
|
||||
isCompleted: i.isCompleted,
|
||||
status: i.status,
|
||||
priority: i.priority,
|
||||
metadata: i.metadata,
|
||||
dueDate: i.dueDate,
|
||||
createdAt: i.createdAt,
|
||||
updatedAt: i.updatedAt
|
||||
}));
|
||||
|
||||
return new NoteList({
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
category: record.category,
|
||||
description: record.description,
|
||||
color: record.color,
|
||||
icon: record.icon,
|
||||
ownerId: record.ownerId,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
items
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user