group events
This commit is contained in:
@@ -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',
|
||||
})
|
||||
})
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
8
costly-api/src/groups/dto/rename-group.dto.ts
Normal file
8
costly-api/src/groups/dto/rename-group.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsString, IsNotEmpty, Length } from 'class-validator';
|
||||
|
||||
export class RenameGroupDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(1, 100)
|
||||
name: string;
|
||||
}
|
||||
6
costly-api/src/groups/dto/update-group.dto.ts
Normal file
6
costly-api/src/groups/dto/update-group.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsOptional, IsString, Length } from "class-validator";
|
||||
|
||||
export class UpdateGroupDto {
|
||||
@IsOptional() @IsString() @Length(1, 100)
|
||||
name?: string;
|
||||
}
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
29
costly-api/src/groups/persistence/group-event.entity.ts
Normal file
29
costly-api/src/groups/persistence/group-event.entity.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
15
costly-api/src/model/dto/incoming-event.dto.ts
Normal file
15
costly-api/src/model/dto/incoming-event.dto.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user