This commit is contained in:
Bastian Wagner 2025-12-20 11:18:07 +01:00
parent e05ab13d0d
commit bb6d7346f7
24 changed files with 519 additions and 115 deletions

12
.postman/config.json Normal file
View File

@ -0,0 +1,12 @@
{
"workspace": {
"id": "ef869800-0dd1-4c11-bb7e-5f8d1c9c1d8a"
},
"entities": {
"collections": [],
"environments": [],
"specs": [],
"flows": [],
"globals": []
}
}

View File

@ -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],

View File

@ -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: [

View File

@ -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();
});
});

View 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);
}
}

View File

@ -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();
});
});

View File

@ -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 };
});
}
}

View File

@ -0,0 +1,5 @@
export class CreateExpenseDto {
name: string;
amount: number;
groupId: number;
}

View 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;
}

View 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 {}

View 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;
}

View 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;
}

View 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);
}
}

View 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);
}
}

View 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
})
}
}

View 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;
}

View 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 {}

View File

@ -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);
}
}

View File

@ -18,93 +18,17 @@ export class GroupsService {
return group;
}
async create(dto: CreateGroupDto, actorId?: string) {
return this.dataSource.transaction(async (manager) => {
const created = await this.groups.createGroup(dto, manager);
getById(id: string) {
return this.groups.findById(id);
}
await manager.getRepository(GroupEventEntity).save({
id: ulid(),
groupId: created.id,
type: 'GROUP_CREATED',
actorId,
payload: {
name: created.name,
},
});
return created;
});
}
async create(dto: CreateGroupDto, actorId?: string) {
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 };
});
}
}

View File

@ -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;

View 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;
}

View File

@ -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[];
}

View File

@ -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;

View 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"
}