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 { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { GroupsModule } from './groups/groups.module';
|
import { GroupsModule } from './groups/groups.module';
|
||||||
|
import { ExpensesModule } from './expenses/expenses.module';
|
||||||
|
import { GroupEventsModule } from './group-events/group-events.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule.forRoot({
|
imports: [ConfigModule.forRoot({
|
||||||
@ -11,7 +13,9 @@ import { GroupsModule } from './groups/groups.module';
|
|||||||
isGlobal: true
|
isGlobal: true
|
||||||
}),
|
}),
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
GroupsModule
|
GroupsModule,
|
||||||
|
ExpensesModule,
|
||||||
|
GroupEventsModule
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@ -3,8 +3,11 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { GroupEntity } from '../groups/persistence/group.entity';
|
import { GroupEntity } from '../groups/persistence/group.entity';
|
||||||
import { GroupEventEntity } from 'src/groups/persistence/group-event.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({
|
@Module({
|
||||||
imports: [
|
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 { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common";
|
||||||
import { CreateGroupDto } from "../dto/create-group.dto";
|
import { CreateGroupDto } from "../dto/create-group.dto";
|
||||||
import { GroupsService } from "../application/groups.service";
|
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'})
|
@Controller({path: 'groups'})
|
||||||
export class GroupsController {
|
export class GroupsController {
|
||||||
@ -15,29 +12,15 @@ export class GroupsController {
|
|||||||
return this.groupsService.getAll();
|
return this.groupsService.getAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
getGroupById(@Param('groupId') groupId: string) {
|
||||||
|
return this.groupsService.getById(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
createGroup(@Body() dto: CreateGroupDto) {
|
createGroup(@Body() dto: CreateGroupDto) {
|
||||||
return this.groupsService.create(dto);
|
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;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateGroupDto, actorId?: string) {
|
getById(id: string) {
|
||||||
return this.dataSource.transaction(async (manager) => {
|
return this.groups.findById(id);
|
||||||
const created = await this.groups.createGroup(dto, manager);
|
}
|
||||||
|
|
||||||
await manager.getRepository(GroupEventEntity).save({
|
async create(dto: CreateGroupDto, actorId?: string) {
|
||||||
id: ulid(),
|
return this.groups.createGroup(dto);
|
||||||
groupId: created.id,
|
}
|
||||||
type: 'GROUP_CREATED',
|
|
||||||
actorId,
|
|
||||||
payload: {
|
|
||||||
name: created.name,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return created;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAll() {
|
async getAll() {
|
||||||
return this.groups.all();
|
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';
|
import { Entity, PrimaryColumn, Column, CreateDateColumn, Index } from 'typeorm';
|
||||||
|
|
||||||
export type GroupEventType =
|
// export type GroupEventType =
|
||||||
| 'GROUP_CREATED'
|
// | 'GROUP_CREATED'
|
||||||
| 'GROUP_RENAMED'
|
// | 'GROUP_RENAMED'
|
||||||
| 'GROUP_INVITE_ROTATED'
|
// | 'GROUP_INVITE_ROTATED'
|
||||||
| 'GROUP_DELETED';
|
// | 'GROUP_DELETED';
|
||||||
|
|
||||||
@Entity('group_events')
|
@Entity('group_events')
|
||||||
@Index(['groupId', 'createdAt'])
|
@Index(['groupId', 'createdAt'])
|
||||||
@ -16,7 +17,7 @@ export class GroupEventEntity {
|
|||||||
groupId: string;
|
groupId: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 64 })
|
@Column({ type: 'varchar', length: 64 })
|
||||||
type: GroupEventType;
|
type: GROUPEVENTTYPE;
|
||||||
|
|
||||||
@Column({ type: 'json' })
|
@Column({ type: 'json' })
|
||||||
payload: any;
|
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')
|
@Entity('groups')
|
||||||
export class GroupEntity {
|
export class GroupEntity {
|
||||||
@ -15,4 +17,9 @@ export class GroupEntity {
|
|||||||
@UpdateDateColumn({ name: 'updated_at' })
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
updatedAt: Date;
|
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 { 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 {
|
export class IncomingEventDto {
|
||||||
@IsString() id: string;
|
@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