members
This commit is contained in:
parent
e05ab13d0d
commit
bb6d7346f7
12
.postman/config.json
Normal file
12
.postman/config.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"workspace": {
|
||||
"id": "ef869800-0dd1-4c11-bb7e-5f8d1c9c1d8a"
|
||||
},
|
||||
"entities": {
|
||||
"collections": [],
|
||||
"environments": [],
|
||||
"specs": [],
|
||||
"flows": [],
|
||||
"globals": []
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,8 @@ import { AppService } from './app.service';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { GroupsModule } from './groups/groups.module';
|
||||
import { ExpensesModule } from './expenses/expenses.module';
|
||||
import { GroupEventsModule } from './group-events/group-events.module';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule.forRoot({
|
||||
@ -11,7 +13,9 @@ import { GroupsModule } from './groups/groups.module';
|
||||
isGlobal: true
|
||||
}),
|
||||
DatabaseModule,
|
||||
GroupsModule
|
||||
GroupsModule,
|
||||
ExpensesModule,
|
||||
GroupEventsModule
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@ -3,8 +3,11 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { GroupEntity } from '../groups/persistence/group.entity';
|
||||
import { GroupEventEntity } from 'src/groups/persistence/group-event.entity';
|
||||
import { ExpenseEntity } from 'src/expenses/persistence/expense.entity';
|
||||
import { ExpenseEventEntity } from 'src/expenses/persistence/expense-event.entity';
|
||||
import { GroupMemberEntity } from 'src/groups/persistence/group-member.entity';
|
||||
|
||||
const ENTITY = [GroupEntity, GroupEventEntity]
|
||||
const ENTITY = [GroupEntity, GroupEventEntity, ExpenseEntity, ExpenseEventEntity, GroupMemberEntity]
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ExpensesController } from './expenses.controller';
|
||||
|
||||
describe('ExpensesController', () => {
|
||||
let controller: ExpensesController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ExpensesController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<ExpensesController>(ExpensesController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
18
costly-api/src/expenses/api/expenses/expenses.controller.ts
Normal file
18
costly-api/src/expenses/api/expenses/expenses.controller.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ExpensesService } from 'src/expenses/application/expenses/expenses.service';
|
||||
import { IncomingExpenseEventDto } from 'src/expenses/dto/incoming-expense-event.dto';
|
||||
import { IncomingEventDto } from 'src/model/dto/incoming-event.dto';
|
||||
|
||||
@Controller('expenses')
|
||||
export class ExpensesController {
|
||||
constructor(private readonly expensesService: ExpensesService) {}
|
||||
|
||||
|
||||
@Post('events/batch')
|
||||
ingest(
|
||||
// @Param('groupId') groupId: string,
|
||||
@Body() events: IncomingExpenseEventDto[],
|
||||
) {
|
||||
return this.expensesService.ingestEvents(events);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ExpensesService } from './expenses.service';
|
||||
|
||||
describe('ExpensesService', () => {
|
||||
let service: ExpensesService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [ExpensesService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ExpensesService>(ExpensesService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,86 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CreateExpenseDto } from 'src/expenses/dto/create-expense.dto';
|
||||
import { IncomingExpenseEventDto } from 'src/expenses/dto/incoming-expense-event.dto';
|
||||
import { ExpenseEventEntity } from 'src/expenses/persistence/expense-event.entity';
|
||||
import { ExpenseEntity } from 'src/expenses/persistence/expense.entity';
|
||||
import { ExpensesRepository } from 'src/expenses/persistence/expense.repository';
|
||||
import { IncomingEventDto } from 'src/model/dto/incoming-event.dto';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
@Injectable()
|
||||
export class ExpensesService {
|
||||
|
||||
constructor(private readonly dataSource: DataSource, private readonly expenses: ExpensesRepository) {}
|
||||
|
||||
|
||||
// async create(dto: CreateExpenseDto, actorId?: string) {
|
||||
// return this.dataSource.transaction(async (manager) => {
|
||||
// const created = await this.expenses.createExpense(dto, manager);
|
||||
|
||||
// await manager.getRepository(ExpenseEntity).save({
|
||||
// id: ulid(),
|
||||
// groupId: created.id,
|
||||
// type: 'GROUP_CREATED',
|
||||
// actorId,
|
||||
// payload: {
|
||||
// name: created.name,
|
||||
// },
|
||||
// });
|
||||
|
||||
// return created;
|
||||
// });
|
||||
// }
|
||||
|
||||
async ingestEvents(events: IncomingExpenseEventDto[]) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const eventsRepo = manager.getRepository(ExpenseEventEntity);
|
||||
const expensesRepo = manager.getRepository(ExpenseEntity);
|
||||
|
||||
// 1) Events persistieren (idempotent)
|
||||
// -> INSERT IGNORE: wenn id schon da, wird es ignoriert
|
||||
await manager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(ExpenseEventEntity)
|
||||
.values(events.map(e => ({
|
||||
id: e.id,
|
||||
expenseId: e.payload.expenseId,
|
||||
type: e.type as any,
|
||||
payload: e.payload,
|
||||
actorId: e.actorId,
|
||||
})))
|
||||
.orIgnore() // MySQL: INSERT IGNORE
|
||||
.execute();
|
||||
|
||||
// 2) Jetzt nur die neu eingefügten Events anwenden
|
||||
// Trick: hole Events anhand IDs aus DB und wende in stabiler Reihenfolge an
|
||||
const persisted = await eventsRepo.find({
|
||||
where: { id: events.map(e => e.id) as any },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
// optional: nur diejenigen anwenden, die "gerade neu" sind
|
||||
// (Wenn du ganz sauber sein willst: execute() liefert affected, aber nicht IDs.
|
||||
// Pragmatik: apply ist idempotent, dann ist doppelt anwenden ok, aber nur wenn Operationen idempotent sind!)
|
||||
for (const ev of persisted) {
|
||||
if (ev.type === 'EXPENSE_AMOUNT_MODIFIED') {
|
||||
const expense = await expensesRepo.findOneByOrFail({ id: ev.payload.expenseId });
|
||||
expense.amount = ev.payload.to;
|
||||
await expensesRepo.save(expense);
|
||||
} else if (ev.type == 'EXPENSE_CREATED') {
|
||||
const dto: CreateExpenseDto = ev.payload
|
||||
const created = await this.expenses.createExpense(dto, manager);
|
||||
ev.payload.expenseId = created.id;
|
||||
eventsRepo.save(ev);
|
||||
} else if (ev.type == 'EXPENSE_NAME_MODIFIED') {
|
||||
const expense = await expensesRepo.findOneByOrFail({ id: ev.payload.expenseId });
|
||||
expense.name = ev.payload.to;
|
||||
await expensesRepo.save(expense);
|
||||
}
|
||||
}
|
||||
|
||||
return { accepted: events.length };
|
||||
});
|
||||
}
|
||||
}
|
||||
5
costly-api/src/expenses/dto/create-expense.dto.ts
Normal file
5
costly-api/src/expenses/dto/create-expense.dto.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export class CreateExpenseDto {
|
||||
name: string;
|
||||
amount: number;
|
||||
groupId: number;
|
||||
}
|
||||
15
costly-api/src/expenses/dto/incoming-expense-event.dto.ts
Normal file
15
costly-api/src/expenses/dto/incoming-expense-event.dto.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { IsString, IsObject, IsOptional, IsISO8601 } from "class-validator";
|
||||
|
||||
export type EXPENSEEVENTTYPE = 'EXPENSE_CREATED' | 'EXPENSE_AMOUNT_MODIFIED' | 'EXPENSE_NAME_MODIFIED';
|
||||
|
||||
export class IncomingExpenseEventDto {
|
||||
@IsString() id: string;
|
||||
@IsString() type: EXPENSEEVENTTYPE;
|
||||
@IsObject() payload: any;
|
||||
|
||||
@IsOptional() @IsString()
|
||||
actorId?: string;
|
||||
|
||||
@IsOptional() @IsISO8601()
|
||||
clientCreatedAt?: string;
|
||||
}
|
||||
18
costly-api/src/expenses/expenses.module.ts
Normal file
18
costly-api/src/expenses/expenses.module.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from 'src/database/database.module';
|
||||
import { ExpensesService } from './application/expenses/expenses.service';
|
||||
import { ExpensesController } from './api/expenses/expenses.controller';
|
||||
import { ExpensesRepository } from './persistence/expense.repository';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ExpenseEntity } from './persistence/expense.entity';
|
||||
import { ExpenseEventEntity } from './persistence/expense-event.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DatabaseModule,
|
||||
TypeOrmModule.forFeature([ExpenseEntity, ExpenseEventEntity])
|
||||
],
|
||||
providers: [ExpensesService, ExpensesRepository],
|
||||
controllers: [ExpensesController]
|
||||
})
|
||||
export class ExpensesModule {}
|
||||
25
costly-api/src/expenses/persistence/expense-event.entity.ts
Normal file
25
costly-api/src/expenses/persistence/expense-event.entity.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Entity, PrimaryColumn, Column, CreateDateColumn, Index } from 'typeorm';
|
||||
import { EXPENSEEVENTTYPE } from '../dto/incoming-expense-event.dto';
|
||||
|
||||
|
||||
@Entity('expense_events')
|
||||
@Index(['expenseId', 'createdAt'])
|
||||
export class ExpenseEventEntity {
|
||||
@PrimaryColumn()
|
||||
id: string; // ULID oder UUID, kommt vom Server oder Client
|
||||
|
||||
@Column()
|
||||
expenseId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 64 })
|
||||
type: EXPENSEEVENTTYPE;
|
||||
|
||||
@Column({ type: 'json' })
|
||||
payload: any;
|
||||
|
||||
@Column({ nullable: true })
|
||||
actorId?: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
25
costly-api/src/expenses/persistence/expense.entity.ts
Normal file
25
costly-api/src/expenses/persistence/expense.entity.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { GroupEntity } from "src/groups/persistence/group.entity";
|
||||
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
|
||||
|
||||
@Entity('expenses')
|
||||
export class ExpenseEntity {
|
||||
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
name: string;
|
||||
|
||||
@OneToMany(type => GroupEntity, group => group.expenses)
|
||||
group: GroupEntity;
|
||||
|
||||
@Column()
|
||||
amount: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
}
|
||||
45
costly-api/src/expenses/persistence/expense.repository.ts
Normal file
45
costly-api/src/expenses/persistence/expense.repository.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { EntityManager, Repository } from "typeorm";
|
||||
import { ExpenseEntity } from "./expense.entity";
|
||||
import { CreateExpenseDto } from "../dto/create-expense.dto";
|
||||
|
||||
@Injectable()
|
||||
export class ExpensesRepository {
|
||||
constructor(
|
||||
@InjectRepository(ExpenseEntity)
|
||||
private readonly repo: Repository<ExpenseEntity>,
|
||||
) {}
|
||||
|
||||
findById(id: string) {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
all() {
|
||||
return this.repo.find();
|
||||
}
|
||||
|
||||
createExpense(
|
||||
dto: CreateExpenseDto,
|
||||
manager?: EntityManager,
|
||||
): Promise<ExpenseEntity> {
|
||||
const r = manager ? manager.getRepository(ExpenseEntity) : this.repo;
|
||||
|
||||
|
||||
const expense = r.create(dto);
|
||||
|
||||
return r.save(expense);
|
||||
}
|
||||
|
||||
private getRepo(manager?: EntityManager) {
|
||||
return manager ? manager.getRepository(ExpenseEntity) : this.repo;
|
||||
}
|
||||
|
||||
async findByIdOrFail(id: string, manager?: EntityManager) {
|
||||
return this.getRepo(manager).findOneByOrFail({ id });
|
||||
}
|
||||
|
||||
async save(group: ExpenseEntity, manager?: EntityManager) {
|
||||
return this.getRepo(manager).save(group);
|
||||
}
|
||||
}
|
||||
29
costly-api/src/group-events/api/group-events.controller.ts
Normal file
29
costly-api/src/group-events/api/group-events.controller.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { GroupEventsService } from '../application/group-events.service';
|
||||
import { IncomingGroupEventDto } from '../dto/group-event.dto';
|
||||
|
||||
@Controller('group-events')
|
||||
export class GroupEventsController {
|
||||
|
||||
constructor(private readonly groupsEventsService: GroupEventsService) {}
|
||||
|
||||
|
||||
@Post(':groupId/events/batch')
|
||||
ingest(
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() events: IncomingGroupEventDto[],
|
||||
) {
|
||||
return this.groupsEventsService.ingestEvents(groupId, events);
|
||||
}
|
||||
|
||||
@Get()
|
||||
getEvents() {
|
||||
return this.groupsEventsService.getLastTenEvents();
|
||||
}
|
||||
|
||||
@Get(':groupdId')
|
||||
getGroupEvents(@Param('groupId') groupId: string) {
|
||||
return this.groupsEventsService.getLastTenEvents(groupId);
|
||||
}
|
||||
|
||||
}
|
||||
111
costly-api/src/group-events/application/group-events.service.ts
Normal file
111
costly-api/src/group-events/application/group-events.service.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { GroupEventEntity } from 'src/groups/persistence/group-event.entity';
|
||||
import { GroupEntity } from 'src/groups/persistence/group.entity';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { CreateExpenseDto } from 'src/expenses/dto/create-expense.dto';
|
||||
import { IncomingExpenseEventDto } from 'src/expenses/dto/incoming-expense-event.dto';
|
||||
import { ExpenseEventEntity } from 'src/expenses/persistence/expense-event.entity';
|
||||
import { ExpenseEntity } from 'src/expenses/persistence/expense.entity';
|
||||
import { GroupsRepository } from 'src/groups/persistence/group.repository';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IncomingEventDto } from 'src/model/dto/incoming-event.dto';
|
||||
import { IncomingGroupEventDto } from '../dto/group-event.dto';
|
||||
import { GroupMemberEntity } from 'src/groups/persistence/group-member.entity';
|
||||
|
||||
@Injectable()
|
||||
export class GroupEventsService {
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
@InjectRepository(GroupEntity)
|
||||
private readonly groupsRepo: Repository<GroupEntity>,
|
||||
@InjectRepository(GroupMemberEntity)
|
||||
private readonly groupMembers: Repository<GroupMemberEntity>
|
||||
) {}
|
||||
|
||||
|
||||
|
||||
|
||||
async ingestEvents(groupId: string, events: IncomingGroupEventDto[]) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const eventsRepo = manager.getRepository(GroupEventEntity);
|
||||
const groupsRepo = manager.getRepository(GroupEntity);
|
||||
|
||||
// 1) Events persistieren (idempotent)
|
||||
// -> INSERT IGNORE: wenn id schon da, wird es ignoriert
|
||||
await manager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(GroupEventEntity)
|
||||
.values(events.map(e => ({
|
||||
id: e.id,
|
||||
groupId,
|
||||
type: e.type as any,
|
||||
payload: e.payload,
|
||||
actorId: e.actorId,
|
||||
})))
|
||||
.orIgnore() // MySQL: INSERT IGNORE
|
||||
.execute();
|
||||
|
||||
// 2) Jetzt nur die neu eingefügten Events anwenden
|
||||
// Trick: hole Events anhand IDs aus DB und wende in stabiler Reihenfolge an
|
||||
const persisted = await eventsRepo.find({
|
||||
where: { id: events.map(e => e.id) as any },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
// optional: nur diejenigen anwenden, die "gerade neu" sind
|
||||
// (Wenn du ganz sauber sein willst: execute() liefert affected, aber nicht IDs.
|
||||
// Pragmatik: apply ist idempotent, dann ist doppelt anwenden ok, aber nur wenn Operationen idempotent sind!)
|
||||
for (const ev of persisted) {
|
||||
if (ev.type === 'GROUP_RENAMED') {
|
||||
await this.ingestGroupRenameEvent(groupId, ev)
|
||||
} else if (ev.type == 'MEMBER_CREATED') {
|
||||
await this.ingestGroupAddMemberEvent(groupId, ev);
|
||||
} else if (ev.type == 'MEMBER_RENAMED') {
|
||||
await this.ingestMemberRenamedEvent(groupId, ev);
|
||||
}
|
||||
}
|
||||
|
||||
return { accepted: events.length };
|
||||
});
|
||||
}
|
||||
|
||||
async ingestMemberRenamedEvent(groupId: string, ev: GroupEventEntity) {
|
||||
if (!ev.payload.to) { throw new BadRequestException('to is required'); }
|
||||
if (!ev.payload.from) { throw new BadRequestException('from is required'); }
|
||||
try {
|
||||
const member = await this.groupMembers.findOneByOrFail({ id: ev.payload.id + '', group: { id: groupId }});
|
||||
member.name = ev.payload.to;
|
||||
await this.groupMembers.save(member);
|
||||
return member;
|
||||
} catch {
|
||||
throw new HttpException('member_not_found', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
}
|
||||
|
||||
async ingestGroupAddMemberEvent(groupId: string, ev: GroupEventEntity) {
|
||||
const group = await this.groupsRepo.findOneByOrFail({ id: groupId });
|
||||
const member = await this.groupMembers.save(this.groupMembers.create({
|
||||
name: ev.payload.name,
|
||||
group: group
|
||||
}))
|
||||
return member;
|
||||
}
|
||||
async ingestGroupRenameEvent(groupId: string, ev: GroupEventEntity) {
|
||||
const group = await this.groupsRepo.findOneByOrFail({ id: groupId });
|
||||
group.name = ev.payload.to;
|
||||
const saved = await this.groupsRepo.save(group);
|
||||
return saved;
|
||||
}
|
||||
|
||||
getLastTenEvents(groupId?: string) {
|
||||
const rep = this.dataSource.getRepository(GroupEventEntity);
|
||||
return rep.find({
|
||||
where: { groupId },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 10
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
15
costly-api/src/group-events/dto/group-event.dto.ts
Normal file
15
costly-api/src/group-events/dto/group-event.dto.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { IsString, IsObject, IsOptional, IsISO8601 } from "class-validator";
|
||||
|
||||
export type GROUPEVENTTYPE = 'GROUP_RENAMED' | 'EXPENSE_CREATED' | 'EXPENSE_AMOUNT_MODIFIED' | 'MEMBER_CREATED' | 'MEMBER_RENAMED';
|
||||
|
||||
export class IncomingGroupEventDto {
|
||||
@IsString() id: string;
|
||||
@IsString() type: GROUPEVENTTYPE;
|
||||
@IsObject() payload: any;
|
||||
|
||||
@IsOptional() @IsString()
|
||||
actorId?: string;
|
||||
|
||||
@IsOptional() @IsISO8601()
|
||||
clientCreatedAt?: string;
|
||||
}
|
||||
14
costly-api/src/group-events/group-events.module.ts
Normal file
14
costly-api/src/group-events/group-events.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GroupEventsController } from './api/group-events.controller';
|
||||
import { GroupEventsService } from './application/group-events.service';
|
||||
import { DatabaseModule } from 'src/database/database.module';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { GroupEntity } from 'src/groups/persistence/group.entity';
|
||||
import { GroupMemberEntity } from 'src/groups/persistence/group-member.entity';
|
||||
|
||||
@Module({
|
||||
controllers: [GroupEventsController],
|
||||
providers: [GroupEventsService],
|
||||
imports: [DatabaseModule, TypeOrmModule.forFeature([GroupEventsController, GroupEntity, GroupMemberEntity])]
|
||||
})
|
||||
export class GroupEventsModule {}
|
||||
@ -1,9 +1,6 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common";
|
||||
import { CreateGroupDto } from "../dto/create-group.dto";
|
||||
import { GroupsService } from "../application/groups.service";
|
||||
import { RenameGroupDto } from "../dto/rename-group.dto";
|
||||
import { IncomingEventDto } from "src/model/dto/incoming-event.dto";
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
@Controller({path: 'groups'})
|
||||
export class GroupsController {
|
||||
@ -15,29 +12,15 @@ export class GroupsController {
|
||||
return this.groupsService.getAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
getGroupById(@Param('groupId') groupId: string) {
|
||||
return this.groupsService.getById(groupId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
createGroup(@Body() dto: CreateGroupDto) {
|
||||
return this.groupsService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id/name')
|
||||
rename(@Param('id') id: string, @Body() dto: RenameGroupDto) {
|
||||
// actorId später z.B. aus Header/Cookie ziehen
|
||||
const event: IncomingEventDto = {
|
||||
id: ulid(),
|
||||
payload: { to: dto.name },
|
||||
type: 'GROUP_RENAMED',
|
||||
actorId: undefined
|
||||
}
|
||||
return this.ingest(id, [event])
|
||||
}
|
||||
|
||||
@Post(':groupId/events/batch')
|
||||
ingest(
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() events: IncomingEventDto[],
|
||||
) {
|
||||
return this.groupsService.ingestEvents(groupId, events);
|
||||
}
|
||||
|
||||
}
|
||||
@ -18,93 +18,17 @@ export class GroupsService {
|
||||
return group;
|
||||
}
|
||||
|
||||
getById(id: string) {
|
||||
return this.groups.findById(id);
|
||||
}
|
||||
|
||||
async create(dto: CreateGroupDto, actorId?: string) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const created = await this.groups.createGroup(dto, manager);
|
||||
|
||||
await manager.getRepository(GroupEventEntity).save({
|
||||
id: ulid(),
|
||||
groupId: created.id,
|
||||
type: 'GROUP_CREATED',
|
||||
actorId,
|
||||
payload: {
|
||||
name: created.name,
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
return this.groups.createGroup(dto);
|
||||
}
|
||||
|
||||
async getAll() {
|
||||
return this.groups.all();
|
||||
}
|
||||
|
||||
// async rename(groupId: string, newName: string, actorId?: string) {
|
||||
// return this.dataSource.transaction(async (manager) => {
|
||||
// const group = await this.groups.findByIdOrFail(groupId, manager);
|
||||
|
||||
// const from = group.name;
|
||||
// const to = newName;
|
||||
|
||||
// // optional: wenn gleich, nix tun (oder trotzdem eventen, wie du willst)
|
||||
// if (from === to) return group;
|
||||
|
||||
// group.name = to;
|
||||
// const saved = await this.groups.save(group, manager);
|
||||
|
||||
// await manager.getRepository(GroupEventEntity).save({
|
||||
// id: ulid(),
|
||||
// groupId: saved.id,
|
||||
// type: 'GROUP_RENAMED',
|
||||
// actorId,
|
||||
// payload: { from, to },
|
||||
// });
|
||||
|
||||
// return saved;
|
||||
// });
|
||||
// }
|
||||
|
||||
async ingestEvents(groupId: string, events: IncomingEventDto[]) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const eventsRepo = manager.getRepository(GroupEventEntity);
|
||||
const groupsRepo = manager.getRepository(GroupEntity);
|
||||
|
||||
// 1) Events persistieren (idempotent)
|
||||
// -> INSERT IGNORE: wenn id schon da, wird es ignoriert
|
||||
await manager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(GroupEventEntity)
|
||||
.values(events.map(e => ({
|
||||
id: e.id,
|
||||
groupId,
|
||||
type: e.type as any,
|
||||
payload: e.payload,
|
||||
actorId: e.actorId,
|
||||
})))
|
||||
.orIgnore() // MySQL: INSERT IGNORE
|
||||
.execute();
|
||||
|
||||
// 2) Jetzt nur die neu eingefügten Events anwenden
|
||||
// Trick: hole Events anhand IDs aus DB und wende in stabiler Reihenfolge an
|
||||
const persisted = await eventsRepo.find({
|
||||
where: { id: events.map(e => e.id) as any },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
// optional: nur diejenigen anwenden, die "gerade neu" sind
|
||||
// (Wenn du ganz sauber sein willst: execute() liefert affected, aber nicht IDs.
|
||||
// Pragmatik: apply ist idempotent, dann ist doppelt anwenden ok, aber nur wenn Operationen idempotent sind!)
|
||||
for (const ev of persisted) {
|
||||
if (ev.type === 'GROUP_RENAMED') {
|
||||
const group = await groupsRepo.findOneByOrFail({ id: groupId });
|
||||
group.name = ev.payload.to;
|
||||
await groupsRepo.save(group);
|
||||
}
|
||||
}
|
||||
|
||||
return { accepted: events.length };
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,11 @@
|
||||
import { GROUPEVENTTYPE } from 'src/group-events/dto/group-event.dto';
|
||||
import { Entity, PrimaryColumn, Column, CreateDateColumn, Index } from 'typeorm';
|
||||
|
||||
export type GroupEventType =
|
||||
| 'GROUP_CREATED'
|
||||
| 'GROUP_RENAMED'
|
||||
| 'GROUP_INVITE_ROTATED'
|
||||
| 'GROUP_DELETED';
|
||||
// export type GroupEventType =
|
||||
// | 'GROUP_CREATED'
|
||||
// | 'GROUP_RENAMED'
|
||||
// | 'GROUP_INVITE_ROTATED'
|
||||
// | 'GROUP_DELETED';
|
||||
|
||||
@Entity('group_events')
|
||||
@Index(['groupId', 'createdAt'])
|
||||
@ -16,7 +17,7 @@ export class GroupEventEntity {
|
||||
groupId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 64 })
|
||||
type: GroupEventType;
|
||||
type: GROUPEVENTTYPE;
|
||||
|
||||
@Column({ type: 'json' })
|
||||
payload: any;
|
||||
|
||||
21
costly-api/src/groups/persistence/group-member.entity.ts
Normal file
21
costly-api/src/groups/persistence/group-member.entity.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
|
||||
import { GroupEntity } from "./group.entity";
|
||||
|
||||
@Entity('group_members')
|
||||
export class GroupMemberEntity {
|
||||
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
name: string;
|
||||
|
||||
@ManyToOne(type => GroupEntity, group => group.members)
|
||||
group: GroupEntity;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
|
||||
import { ExpenseEntity } from "src/expenses/persistence/expense.entity";
|
||||
import { Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
|
||||
import { GroupMemberEntity } from "./group-member.entity";
|
||||
|
||||
@Entity('groups')
|
||||
export class GroupEntity {
|
||||
@ -15,4 +17,9 @@ export class GroupEntity {
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
expenses: ExpenseEntity[] = [];
|
||||
|
||||
@OneToMany(type => GroupMemberEntity, member => member.group, { eager: true })
|
||||
members: GroupMemberEntity[];
|
||||
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { IsString, IsObject, IsOptional, IsISO8601 } from "class-validator";
|
||||
import { GROUPEVENTTYPE } from "src/group-events/dto/group-event.dto";
|
||||
|
||||
export type GROUPEVENTTYPE = 'GROUP_RENAMED'
|
||||
|
||||
export class IncomingEventDto {
|
||||
@IsString() id: string;
|
||||
|
||||
7
postman/globals/workspace.postman_globals.json
Normal file
7
postman/globals/workspace.postman_globals.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "1c3b9510-1b40-4e54-9aa7-b814e2b1722f",
|
||||
"name": "Globals",
|
||||
"values": [],
|
||||
"_postman_variable_scope": "globals",
|
||||
"_postman_exported_at": "2025-12-20T09:27:05.474Z"
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user