group events

This commit is contained in:
Bastian Wagner
2025-12-19 13:14:04 +01:00
parent 5a08e2319f
commit e05ab13d0d
16 changed files with 442 additions and 101 deletions

View File

@@ -2,8 +2,9 @@ import { Module } from '@nestjs/common';
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';
const ENTITY = [GroupEntity]
const ENTITY = [GroupEntity, GroupEventEntity]
@Module({
imports: [
@@ -19,7 +20,7 @@ const ENTITY = [GroupEntity]
password: configService.get('DATABASE_PASSWORD'),
database: configService.get('DATABASE_NAME'),
entities: ENTITY,
synchronize: true,
synchronize: (configService.get('DATABASE_SYNC')||'').toLowerCase() == 'true',
})
})
],

View File

@@ -1,15 +1,43 @@
import { Body, Controller, Post } from "@nestjs/common";
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 {
constructor(private groupsService: GroupsService) {}
@Get()
getGroups() {
return this.groupsService.getAll();
}
@Post()
createGroup(@Body() dto: CreateGroupDto) {
return this.groupsService.create(dto);
}
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

@@ -1,10 +1,16 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { GroupsRepository } from "../persistence/group.repository";
import { CreateGroupDto } from "../dto/create-group.dto";
import { UpdateGroupDto } from "../dto/update-group.dto";
import { DataSource } from "typeorm";
import { GroupEntity } from "../persistence/group.entity";
import { GroupEventEntity } from "../persistence/group-event.entity";
import { ulid } from 'ulid';
import { IncomingEventDto } from "src/model/dto/incoming-event.dto";
@Injectable()
export class GroupsService {
constructor(private readonly groups: GroupsRepository) {}
constructor(private readonly dataSource: DataSource, private readonly groups: GroupsRepository) {}
async join(id: string) {
const group = await this.groups.findById(id);
@@ -12,7 +18,93 @@ export class GroupsService {
return group;
}
async create(dto: CreateGroupDto) {
return this.groups.create(dto);
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;
});
}
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

@@ -0,0 +1,8 @@
import { IsString, IsNotEmpty, Length } from 'class-validator';
export class RenameGroupDto {
@IsString()
@IsNotEmpty()
@Length(1, 100)
name: string;
}

View File

@@ -0,0 +1,6 @@
import { IsOptional, IsString, Length } from "class-validator";
export class UpdateGroupDto {
@IsOptional() @IsString() @Length(1, 100)
name?: string;
}

View File

@@ -4,9 +4,10 @@ import { GroupEntity } from './persistence/group.entity';
import { GroupsController } from './api/groups.controller';
import { GroupsService } from './application/groups.service';
import { GroupsRepository } from './persistence/group.repository';
import { GroupEventEntity } from './persistence/group-event.entity';
@Module({
imports: [TypeOrmModule.forFeature([GroupEntity])],
imports: [TypeOrmModule.forFeature([GroupEntity, GroupEventEntity])],
controllers: [GroupsController],
providers: [GroupsService, GroupsRepository]
})

View File

@@ -0,0 +1,29 @@
import { Entity, PrimaryColumn, Column, CreateDateColumn, Index } from 'typeorm';
export type GroupEventType =
| 'GROUP_CREATED'
| 'GROUP_RENAMED'
| 'GROUP_INVITE_ROTATED'
| 'GROUP_DELETED';
@Entity('group_events')
@Index(['groupId', 'createdAt'])
export class GroupEventEntity {
@PrimaryColumn()
id: string; // ULID oder UUID, kommt vom Server oder Client
@Column()
groupId: string;
@Column({ type: 'varchar', length: 64 })
type: GroupEventType;
@Column({ type: 'json' })
payload: any;
@Column({ nullable: true })
actorId?: string;
@CreateDateColumn()
createdAt: Date;
}

View File

@@ -1,4 +1,4 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
@Entity('groups')
export class GroupEntity {
@@ -6,7 +6,13 @@ export class GroupEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({nullable: false})
@Column({ nullable: false })
name: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -1,8 +1,9 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { EntityManager, Repository } from "typeorm";
import { GroupEntity } from "./group.entity";
import { CreateGroupDto } from "../dto/create-group.dto";
import { UpdateGroupDto } from "../dto/update-group.dto";
@Injectable()
export class GroupsRepository {
@@ -11,15 +12,36 @@ export class GroupsRepository {
private readonly repo: Repository<GroupEntity>,
) {}
save(group: GroupEntity) {
return this.repo.save(group);
}
findById(id: string) {
return this.repo.findOne({ where: { id } });
}
create(dto: CreateGroupDto) {
return this.repo.save(this.repo.create(dto))
all() {
return this.repo.find();
}
createGroup(
dto: CreateGroupDto,
manager?: EntityManager,
): Promise<GroupEntity> {
const r = manager ? manager.getRepository(GroupEntity) : this.repo;
const group = r.create({
name: dto.name,
});
return r.save(group);
}
private getRepo(manager?: EntityManager) {
return manager ? manager.getRepository(GroupEntity) : this.repo;
}
async findByIdOrFail(id: string, manager?: EntityManager) {
return this.getRepo(manager).findOneByOrFail({ id });
}
async save(group: GroupEntity, manager?: EntityManager) {
return this.getRepo(manager).save(group);
}
}

View File

@@ -0,0 +1,15 @@
import { IsString, IsObject, IsOptional, IsISO8601 } from "class-validator";
export type GROUPEVENTTYPE = 'GROUP_RENAMED'
export class IncomingEventDto {
@IsString() id: string;
@IsString() type: GROUPEVENTTYPE;
@IsObject() payload: any;
@IsOptional() @IsString()
actorId?: string;
@IsOptional() @IsISO8601()
clientCreatedAt?: string;
}