From bb6d7346f7e8e410a3d8aae7d987218892945d36 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 20 Dec 2025 11:18:07 +0100 Subject: [PATCH] members --- .postman/config.json | 12 ++ costly-api/src/app.module.ts | 6 +- costly-api/src/database/database.module.ts | 5 +- .../api/expenses/expenses.controller.spec.ts | 18 +++ .../api/expenses/expenses.controller.ts | 18 +++ .../expenses/expenses.service.spec.ts | 18 +++ .../application/expenses/expenses.service.ts | 86 ++++++++++++++ .../src/expenses/dto/create-expense.dto.ts | 5 + .../dto/incoming-expense-event.dto.ts | 15 +++ costly-api/src/expenses/expenses.module.ts | 18 +++ .../persistence/expense-event.entity.ts | 25 ++++ .../expenses/persistence/expense.entity.ts | 25 ++++ .../persistence/expense.repository.ts | 45 +++++++ .../api/group-events.controller.ts | 29 +++++ .../application/group-events.service.ts | 111 ++++++++++++++++++ .../src/group-events/dto/group-event.dto.ts | 15 +++ .../src/group-events/group-events.module.ts | 14 +++ .../src/groups/api/groups.controller.ts | 27 +---- .../src/groups/application/groups.service.ts | 90 ++------------ .../groups/persistence/group-event.entity.ts | 13 +- .../groups/persistence/group-member.entity.ts | 21 ++++ .../src/groups/persistence/group.entity.ts | 9 +- .../src/model/dto/incoming-event.dto.ts | 2 +- .../globals/workspace.postman_globals.json | 7 ++ 24 files changed, 519 insertions(+), 115 deletions(-) create mode 100644 .postman/config.json create mode 100644 costly-api/src/expenses/api/expenses/expenses.controller.spec.ts create mode 100644 costly-api/src/expenses/api/expenses/expenses.controller.ts create mode 100644 costly-api/src/expenses/application/expenses/expenses.service.spec.ts create mode 100644 costly-api/src/expenses/application/expenses/expenses.service.ts create mode 100644 costly-api/src/expenses/dto/create-expense.dto.ts create mode 100644 costly-api/src/expenses/dto/incoming-expense-event.dto.ts create mode 100644 costly-api/src/expenses/expenses.module.ts create mode 100644 costly-api/src/expenses/persistence/expense-event.entity.ts create mode 100644 costly-api/src/expenses/persistence/expense.entity.ts create mode 100644 costly-api/src/expenses/persistence/expense.repository.ts create mode 100644 costly-api/src/group-events/api/group-events.controller.ts create mode 100644 costly-api/src/group-events/application/group-events.service.ts create mode 100644 costly-api/src/group-events/dto/group-event.dto.ts create mode 100644 costly-api/src/group-events/group-events.module.ts create mode 100644 costly-api/src/groups/persistence/group-member.entity.ts create mode 100644 postman/globals/workspace.postman_globals.json diff --git a/.postman/config.json b/.postman/config.json new file mode 100644 index 0000000..4060d09 --- /dev/null +++ b/.postman/config.json @@ -0,0 +1,12 @@ +{ + "workspace": { + "id": "ef869800-0dd1-4c11-bb7e-5f8d1c9c1d8a" + }, + "entities": { + "collections": [], + "environments": [], + "specs": [], + "flows": [], + "globals": [] + } +} \ No newline at end of file diff --git a/costly-api/src/app.module.ts b/costly-api/src/app.module.ts index cee5d64..12ffec2 100644 --- a/costly-api/src/app.module.ts +++ b/costly-api/src/app.module.ts @@ -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], diff --git a/costly-api/src/database/database.module.ts b/costly-api/src/database/database.module.ts index e52b551..7f44f03 100644 --- a/costly-api/src/database/database.module.ts +++ b/costly-api/src/database/database.module.ts @@ -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: [ diff --git a/costly-api/src/expenses/api/expenses/expenses.controller.spec.ts b/costly-api/src/expenses/api/expenses/expenses.controller.spec.ts new file mode 100644 index 0000000..314758c --- /dev/null +++ b/costly-api/src/expenses/api/expenses/expenses.controller.spec.ts @@ -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); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/costly-api/src/expenses/api/expenses/expenses.controller.ts b/costly-api/src/expenses/api/expenses/expenses.controller.ts new file mode 100644 index 0000000..997586f --- /dev/null +++ b/costly-api/src/expenses/api/expenses/expenses.controller.ts @@ -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); + } +} diff --git a/costly-api/src/expenses/application/expenses/expenses.service.spec.ts b/costly-api/src/expenses/application/expenses/expenses.service.spec.ts new file mode 100644 index 0000000..c446139 --- /dev/null +++ b/costly-api/src/expenses/application/expenses/expenses.service.spec.ts @@ -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); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/costly-api/src/expenses/application/expenses/expenses.service.ts b/costly-api/src/expenses/application/expenses/expenses.service.ts new file mode 100644 index 0000000..1b6a00f --- /dev/null +++ b/costly-api/src/expenses/application/expenses/expenses.service.ts @@ -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 }; + }); + } +} diff --git a/costly-api/src/expenses/dto/create-expense.dto.ts b/costly-api/src/expenses/dto/create-expense.dto.ts new file mode 100644 index 0000000..a8c084d --- /dev/null +++ b/costly-api/src/expenses/dto/create-expense.dto.ts @@ -0,0 +1,5 @@ +export class CreateExpenseDto { + name: string; + amount: number; + groupId: number; +} \ No newline at end of file diff --git a/costly-api/src/expenses/dto/incoming-expense-event.dto.ts b/costly-api/src/expenses/dto/incoming-expense-event.dto.ts new file mode 100644 index 0000000..3055960 --- /dev/null +++ b/costly-api/src/expenses/dto/incoming-expense-event.dto.ts @@ -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; +} \ No newline at end of file diff --git a/costly-api/src/expenses/expenses.module.ts b/costly-api/src/expenses/expenses.module.ts new file mode 100644 index 0000000..42cc44b --- /dev/null +++ b/costly-api/src/expenses/expenses.module.ts @@ -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 {} diff --git a/costly-api/src/expenses/persistence/expense-event.entity.ts b/costly-api/src/expenses/persistence/expense-event.entity.ts new file mode 100644 index 0000000..13d3703 --- /dev/null +++ b/costly-api/src/expenses/persistence/expense-event.entity.ts @@ -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; +} \ No newline at end of file diff --git a/costly-api/src/expenses/persistence/expense.entity.ts b/costly-api/src/expenses/persistence/expense.entity.ts new file mode 100644 index 0000000..051fe53 --- /dev/null +++ b/costly-api/src/expenses/persistence/expense.entity.ts @@ -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; + +} \ No newline at end of file diff --git a/costly-api/src/expenses/persistence/expense.repository.ts b/costly-api/src/expenses/persistence/expense.repository.ts new file mode 100644 index 0000000..f7e61db --- /dev/null +++ b/costly-api/src/expenses/persistence/expense.repository.ts @@ -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, + ) {} + + findById(id: string) { + return this.repo.findOne({ where: { id } }); + } + + all() { + return this.repo.find(); + } + +createExpense( + dto: CreateExpenseDto, + manager?: EntityManager, + ): Promise { + 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); + } +} \ No newline at end of file diff --git a/costly-api/src/group-events/api/group-events.controller.ts b/costly-api/src/group-events/api/group-events.controller.ts new file mode 100644 index 0000000..6d01945 --- /dev/null +++ b/costly-api/src/group-events/api/group-events.controller.ts @@ -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); + } + +} diff --git a/costly-api/src/group-events/application/group-events.service.ts b/costly-api/src/group-events/application/group-events.service.ts new file mode 100644 index 0000000..0dc1a45 --- /dev/null +++ b/costly-api/src/group-events/application/group-events.service.ts @@ -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, + @InjectRepository(GroupMemberEntity) + private readonly groupMembers: Repository + ) {} + + + + + 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 + + }) + } +} diff --git a/costly-api/src/group-events/dto/group-event.dto.ts b/costly-api/src/group-events/dto/group-event.dto.ts new file mode 100644 index 0000000..4b70e48 --- /dev/null +++ b/costly-api/src/group-events/dto/group-event.dto.ts @@ -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; +} \ No newline at end of file diff --git a/costly-api/src/group-events/group-events.module.ts b/costly-api/src/group-events/group-events.module.ts new file mode 100644 index 0000000..fd05966 --- /dev/null +++ b/costly-api/src/group-events/group-events.module.ts @@ -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 {} diff --git a/costly-api/src/groups/api/groups.controller.ts b/costly-api/src/groups/api/groups.controller.ts index e904f2c..aaf7165 100644 --- a/costly-api/src/groups/api/groups.controller.ts +++ b/costly-api/src/groups/api/groups.controller.ts @@ -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); - } } \ No newline at end of file diff --git a/costly-api/src/groups/application/groups.service.ts b/costly-api/src/groups/application/groups.service.ts index 6b0b160..a8b3351 100644 --- a/costly-api/src/groups/application/groups.service.ts +++ b/costly-api/src/groups/application/groups.service.ts @@ -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 }; - }); - } + } \ No newline at end of file diff --git a/costly-api/src/groups/persistence/group-event.entity.ts b/costly-api/src/groups/persistence/group-event.entity.ts index 2163c2d..c1d09bb 100644 --- a/costly-api/src/groups/persistence/group-event.entity.ts +++ b/costly-api/src/groups/persistence/group-event.entity.ts @@ -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; diff --git a/costly-api/src/groups/persistence/group-member.entity.ts b/costly-api/src/groups/persistence/group-member.entity.ts new file mode 100644 index 0000000..d4f31b6 --- /dev/null +++ b/costly-api/src/groups/persistence/group-member.entity.ts @@ -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; +} \ No newline at end of file diff --git a/costly-api/src/groups/persistence/group.entity.ts b/costly-api/src/groups/persistence/group.entity.ts index 5cdeb14..205172e 100644 --- a/costly-api/src/groups/persistence/group.entity.ts +++ b/costly-api/src/groups/persistence/group.entity.ts @@ -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[]; + } \ No newline at end of file diff --git a/costly-api/src/model/dto/incoming-event.dto.ts b/costly-api/src/model/dto/incoming-event.dto.ts index f4e09c1..4921997 100644 --- a/costly-api/src/model/dto/incoming-event.dto.ts +++ b/costly-api/src/model/dto/incoming-event.dto.ts @@ -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; diff --git a/postman/globals/workspace.postman_globals.json b/postman/globals/workspace.postman_globals.json new file mode 100644 index 0000000..60f2c7d --- /dev/null +++ b/postman/globals/workspace.postman_globals.json @@ -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" +} \ No newline at end of file