This commit is contained in:
Bastian Wagner 2025-12-22 15:51:59 +01:00
parent bb6d7346f7
commit 88f827d4b4
17 changed files with 259 additions and 46 deletions

View File

@ -16,6 +16,7 @@
"@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"express-session": "^1.18.2",
"mysql2": "^3.16.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
@ -31,6 +32,7 @@
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7",
"@types/express": "^5.0.0",
"@types/express-session": "^1.18.2",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
@ -3362,6 +3364,16 @@
"@types/send": "*"
}
},
"node_modules/@types/express-session": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz",
"integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@ -6368,6 +6380,46 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-session": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
"integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.7",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/express-session/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/express-session/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/express/node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@ -9072,6 +9124,15 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -9626,6 +9687,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -11456,6 +11526,18 @@
"node": ">=8"
}
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/uint8array-extras": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",

View File

@ -27,6 +27,7 @@
"@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"express-session": "^1.18.2",
"mysql2": "^3.16.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
@ -42,6 +43,7 @@
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7",
"@types/express": "^5.0.0",
"@types/express-session": "^1.18.2",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",

View File

@ -6,16 +6,31 @@ 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';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [ConfigModule.forRoot({
envFilePath: '.env',
isGlobal: true
}),
DatabaseModule,
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'mysql',
host: configService.get('DATABASE_HOST') || 'localhost',
port: parseInt(configService.get('DATABASE_PORT') || '0'),
username: configService.get('DATABASE_USER'),
password: configService.get('DATABASE_PASSWORD'),
database: configService.get('DATABASE_NAME'),
entities: [],
synchronize: (configService.get('DATABASE_SYNC')||'').toLowerCase() == 'true',
})
}),
GroupsModule,
ExpensesModule,
GroupEventsModule
GroupEventsModule,
DatabaseModule
],
controllers: [AppController],
providers: [AppService],

View File

@ -6,26 +6,14 @@ 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';
import { ExpenseSplitEntity } from 'src/expenses/persistence/expense-split.entity';
const ENTITY = [GroupEntity, GroupEventEntity, ExpenseEntity, ExpenseEventEntity, GroupMemberEntity]
const ENTITY = [GroupEntity, GroupEventEntity, ExpenseEntity, ExpenseEventEntity, GroupMemberEntity, ExpenseSplitEntity]
@Module({
imports: [
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'mysql',
host: configService.get('DATABASE_HOST') || 'localhost',
port: parseInt(configService.get('DATABASE_PORT') || '0'),
username: configService.get('DATABASE_USER'),
password: configService.get('DATABASE_PASSWORD'),
database: configService.get('DATABASE_NAME'),
entities: ENTITY,
synchronize: (configService.get('DATABASE_SYNC')||'').toLowerCase() == 'true',
})
})
TypeOrmModule.forFeature(ENTITY)
],
providers: [ConfigService,],
exports: [TypeOrmModule]

View File

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

View File

@ -3,14 +3,10 @@ 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])
DatabaseModule
],
providers: [ExpensesService, ExpensesRepository],
controllers: [ExpensesController]

View File

@ -0,0 +1,19 @@
import { Column, Entity, ManyToOne, PrimaryColumn, PrimaryGeneratedColumn } from "typeorm";
import { ExpenseEntity } from "./expense.entity";
import { GroupMemberEntity } from "src/groups/persistence/group-member.entity";
@Entity('expense_splits')
export class ExpenseSplitEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(expense => ExpenseEntity, expense => expense.splits)
expense: ExpenseEntity;
@Column({ type: 'float'})
weight: number;
@ManyToOne(member => GroupMemberEntity, member => member.splits, { eager: true })
member: GroupMemberEntity;
}

View File

@ -1,5 +1,6 @@
import { GroupEntity } from "src/groups/persistence/group.entity";
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import { Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import { ExpenseSplitEntity } from "./expense-split.entity";
@Entity('expenses')
export class ExpenseEntity {
@ -10,9 +11,12 @@ export class ExpenseEntity {
@Column({ nullable: false })
name: string;
@OneToMany(type => GroupEntity, group => group.expenses)
@ManyToOne(type => GroupEntity, group => group.expenses)
group: GroupEntity;
@OneToMany(type => ExpenseSplitEntity, split => split.expense, { eager: true, cascade: true})
splits: ExpenseSplitEntity[];
@Column()
amount: number;

View File

@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Req, Session } from '@nestjs/common';
import { GroupEventsService } from '../application/group-events.service';
import { IncomingGroupEventDto } from '../dto/group-event.dto';
@ -17,13 +17,13 @@ export class GroupEventsController {
}
@Get()
getEvents() {
return this.groupsEventsService.getLastTenEvents();
getEvents(@Session() session: Record<string, any>) {
return this.groupsEventsService.getLastEvents({});
}
@Get(':groupdId')
getGroupEvents(@Param('groupId') groupId: string) {
return this.groupsEventsService.getLastTenEvents(groupId);
return this.groupsEventsService.getLastEvents({groupId});
}
}

View File

@ -9,8 +9,9 @@ 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 { GroupEventType, GROUPEVENTTYPE, GroupRenamedPayload, IncomingGroupEventDto, TypedIncomingGroupEvent } from '../dto/group-event.dto';
import { GroupMemberEntity } from 'src/groups/persistence/group-member.entity';
import { ExpenseSplitEntity } from 'src/expenses/persistence/expense-split.entity';
@Injectable()
export class GroupEventsService {
@ -57,13 +58,15 @@ export class GroupEventsService {
// 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) {
for (const ev of (persisted as TypedIncomingGroupEvent[])) {
if (ev.type === 'GROUP_RENAMED') {
await this.ingestGroupRenameEvent(groupId, ev)
await this.ingestGroupRenameEvent(groupId, ev as any)
} else if (ev.type == 'MEMBER_CREATED') {
await this.ingestGroupAddMemberEvent(groupId, ev);
await this.ingestGroupAddMemberEvent(groupId, ev as any);
} else if (ev.type == 'MEMBER_RENAMED') {
await this.ingestMemberRenamedEvent(groupId, ev);
await this.ingestMemberRenamedEvent(groupId, ev as any);
} else if (ev.type == 'EXPENSE_CREATED') {
await this.ingestCreateExpense(groupId, ev as any)
}
}
@ -71,11 +74,11 @@ export class GroupEventsService {
});
}
async ingestMemberRenamedEvent(groupId: string, ev: GroupEventEntity) {
async ingestMemberRenamedEvent(groupId: string, ev: TypedIncomingGroupEvent<'MEMBER_RENAMED'>) {
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 }});
const member = await this.groupMembers.findOneByOrFail({ id: ev.payload.memberId + '', group: { id: groupId }});
member.name = ev.payload.to;
await this.groupMembers.save(member);
return member;
@ -84,7 +87,7 @@ export class GroupEventsService {
}
}
async ingestGroupAddMemberEvent(groupId: string, ev: GroupEventEntity) {
async ingestGroupAddMemberEvent(groupId: string, ev: TypedIncomingGroupEvent<'MEMBER_CREATED'>) {
const group = await this.groupsRepo.findOneByOrFail({ id: groupId });
const member = await this.groupMembers.save(this.groupMembers.create({
name: ev.payload.name,
@ -92,20 +95,43 @@ export class GroupEventsService {
}))
return member;
}
async ingestGroupRenameEvent(groupId: string, ev: GroupEventEntity) {
async ingestGroupRenameEvent(groupId: string, ev: TypedIncomingGroupEvent<'GROUP_RENAMED'>) {
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) {
getLastEvents({groupId, amount}: {groupId?: string, amount?: 10}) {
const rep = this.dataSource.getRepository(GroupEventEntity);
return rep.find({
where: { groupId },
order: { createdAt: 'DESC' },
take: 10
take: amount
})
}
async ingestCreateExpense(groupId: string, ev: GroupEventEntity) {
const group = await this.groupsRepo.findOneByOrFail({ id: groupId });
const repo = this.dataSource.getRepository(ExpenseEntity);
const splitRepo = this.dataSource.getRepository(ExpenseSplitEntity);
const dto = ev.payload as CreateExpenseDto;
dto.groupId = groupId;
const exp = repo.create(dto);
exp.group = group;
const expense = await repo.save(exp)
for (let m of group.members) {
const s = await splitRepo.save(
splitRepo.create({
expense,
member: m,
weight: 1 / group.members.length
})
)
console.log(s)
}
return exp;
}
}

View File

@ -13,3 +13,55 @@ export class IncomingGroupEventDto {
@IsOptional() @IsISO8601()
clientCreatedAt?: string;
}
export type GroupEventType =
| 'GROUP_RENAMED'
| 'EXPENSE_CREATED'
| 'EXPENSE_AMOUNT_MODIFIED'
| 'MEMBER_CREATED'
| 'MEMBER_RENAMED';
export interface GroupRenamedPayload {
from: string;
to: string;
}
export interface ExpenseCreatedPayload {
expenseId: string;
payerMemberId: string;
amountCents: number;
splits: Array<{ memberId: string; weight: number }>;
}
export interface ExpenseAmountModifiedPayload {
expenseId: string;
fromAmountCents: number;
toAmountCents: number;
}
export interface MemberCreatedPayload {
memberId: string;
name: string;
}
export interface MemberRenamedPayload {
memberId: string;
from: string;
to: string;
}
export type GroupEventPayloadByType = {
GROUP_RENAMED: GroupRenamedPayload;
EXPENSE_CREATED: ExpenseCreatedPayload;
EXPENSE_AMOUNT_MODIFIED: ExpenseAmountModifiedPayload;
MEMBER_CREATED: MemberCreatedPayload;
MEMBER_RENAMED: MemberRenamedPayload;
};
export type TypedIncomingGroupEvent<T extends GroupEventType = GroupEventType> = {
id: string;
type: T;
payload: GroupEventPayloadByType[T];
actorId?: string;
clientCreatedAt?: string;
};

View File

@ -9,6 +9,6 @@ import { GroupMemberEntity } from 'src/groups/persistence/group-member.entity';
@Module({
controllers: [GroupEventsController],
providers: [GroupEventsService],
imports: [DatabaseModule, TypeOrmModule.forFeature([GroupEventsController, GroupEntity, GroupMemberEntity])]
imports: [DatabaseModule]
})
export class GroupEventsModule {}

View File

@ -5,9 +5,10 @@ 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';
import { DatabaseModule } from 'src/database/database.module';
@Module({
imports: [TypeOrmModule.forFeature([GroupEntity, GroupEventEntity])],
imports: [DatabaseModule],
controllers: [GroupsController],
providers: [GroupsService, GroupsRepository]
})

View File

@ -1,5 +1,6 @@
import { Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import { GroupEntity } from "./group.entity";
import { ExpenseSplitEntity } from "src/expenses/persistence/expense-split.entity";
@Entity('group_members')
export class GroupMemberEntity {
@ -10,9 +11,14 @@ export class GroupMemberEntity {
@Column({ nullable: false })
name: string;
balance: number = 0;
@ManyToOne(type => GroupEntity, group => group.members)
group: GroupEntity;
@OneToMany(type => ExpenseSplitEntity, split => split.member)
splits: ExpenseSplitEntity[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@ -17,7 +17,8 @@ export class GroupEntity {
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
expenses: ExpenseEntity[] = [];
@OneToMany(type => ExpenseEntity, expense => expense.group, { eager: true })
expenses: ExpenseEntity[];
@OneToMany(type => GroupMemberEntity, member => member.group, { eager: true })
members: GroupMemberEntity[];

View File

@ -12,14 +12,24 @@ export class GroupsRepository {
private readonly repo: Repository<GroupEntity>,
) {}
findById(id: string) {
return this.repo.findOne({ where: { id } });
async findById(id: string) {
const group = await this.repo.findOneOrFail({ where: { id } });
this.addTotals(group);
return group;
}
all() {
return this.repo.find();
}
addTotals(group: GroupEntity) {
for (let m of group.members) {
const expenses = group.expenses.filter(ex => ex.splits.some( s => s.member.id == m.id));
const total = expenses.reduce((a, b) => a + b.amount * ((b.splits || []).find(s => s.member.id == m.id)?.weight || 0), 0);
m.balance = m.balance - total;
}
}
createGroup(
dto: CreateGroupDto,
manager?: EntityManager,

View File

@ -1,9 +1,20 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import * as session from 'express-session';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// somewhere in your initialization file
app.use(
session({
secret: 'sdfjsodjf',
resave: false,
saveUninitialized: false,
}),
);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // entfernt unbekannte Felder