This commit is contained in:
Bastian Wagner
2026-06-10 15:44:18 +02:00
parent 67b5fb8532
commit e1cc78ca27
22 changed files with 749 additions and 26 deletions

View File

@@ -17,6 +17,8 @@ export type AuditAction =
| 'list.created_from_template' | 'list.created_from_template'
| 'list.updated' | 'list.updated'
| 'list.deleted' | 'list.deleted'
| 'list.shared'
| 'list.unshared'
| 'list.item_created' | 'list.item_created'
| 'list.item_updated' | 'list.item_updated'
| 'list.item_checked' | 'list.item_checked'

View File

@@ -55,6 +55,15 @@ export class AuthController {
return this.authService.getPublicUser(request.user!.sub); return this.authService.getPublicUser(request.user!.sub);
} }
@Get('users/search')
@UseGuards(JwtAuthGuard)
searchUsers(
@Req() request: AuthenticatedRequest,
@Query('q') query?: string,
) {
return this.authService.searchUsers(request.user!.sub, query);
}
@Patch('me/onboarding') @Patch('me/onboarding')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
updateOnboarding( updateOnboarding(

View File

@@ -9,7 +9,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto'; import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto';
import { Repository } from 'typeorm'; import { Like, Repository } from 'typeorm';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
@@ -20,6 +20,7 @@ import {
AuthTokens, AuthTokens,
JwtTokenPayload, JwtTokenPayload,
PublicUser, PublicUser,
PublicUserSearchResult,
} from './auth.types'; } from './auth.types';
import { AppEvents } from '../events/app-events'; import { AppEvents } from '../events/app-events';
import { RefreshTokenEntity } from './refresh-token.entity'; import { RefreshTokenEntity } from './refresh-token.entity';
@@ -293,6 +294,41 @@ export class AuthService {
return this.toPublicUser(user); return this.toPublicUser(user);
} }
async searchUsers(
actorUserId: string,
query?: string,
): Promise<PublicUserSearchResult[]> {
const normalizedQuery = query?.trim();
if (!normalizedQuery || normalizedQuery.length < 2) {
return [];
}
const pattern = `%${normalizedQuery}%`;
const users = await this.usersRepository.find({
where: [
{ verified: true, email: Like(pattern) },
{ verified: true, name: Like(pattern) },
],
order: { email: 'ASC' },
take: 10,
});
return users
.filter((user) => user.id !== actorUserId)
.filter(
(user, index, allUsers) =>
allUsers.findIndex((existingUser) => existingUser.id === user.id) ===
index,
)
.slice(0, 10)
.map((user) => ({
id: user.id,
email: user.email,
name: user.name ?? undefined,
}));
}
async updateOnboardingCompleted( async updateOnboardingCompleted(
userId: string, userId: string,
completed: boolean, completed: boolean,

View File

@@ -37,3 +37,9 @@ export interface PublicUser {
verified: boolean; verified: boolean;
onboardingCompleted: boolean; onboardingCompleted: boolean;
} }
export interface PublicUserSearchResult {
id: string;
email: string;
name?: string;
}

View File

@@ -9,6 +9,7 @@ import {
} from 'typeorm'; } from 'typeorm';
import { ListTemplateEntity } from '../list-templates/list-template.entity'; import { ListTemplateEntity } from '../list-templates/list-template.entity';
import { UserListEntity } from '../lists/user-list.entity'; import { UserListEntity } from '../lists/user-list.entity';
import { UserListShareEntity } from '../lists/user-list-share.entity';
import { RefreshTokenEntity } from './refresh-token.entity'; import { RefreshTokenEntity } from './refresh-token.entity';
@Entity('users') @Entity('users')
@@ -59,4 +60,7 @@ export class UserEntity {
@OneToMany(() => UserListEntity, (list) => list.owner) @OneToMany(() => UserListEntity, (list) => list.owner)
lists?: UserListEntity[]; lists?: UserListEntity[];
@OneToMany(() => UserListShareEntity, (share) => share.user)
sharedLists?: UserListShareEntity[];
} }

View File

@@ -38,14 +38,27 @@ export interface UserListItem {
updatedAt: string; updatedAt: string;
} }
export type UserListAccessRole = 'owner' | 'collaborator';
export interface UserListCollaborator {
id: string;
name?: string;
email: string;
role: 'collaborator';
}
export interface UserList { export interface UserList {
id: string; id: string;
ownerId: string; ownerId: string;
ownerName?: string;
ownerEmail?: string;
accessRole: UserListAccessRole;
sourceTemplateId?: string; sourceTemplateId?: string;
name: string; name: string;
description?: string; description?: string;
kind: ListTemplateKind; kind: ListTemplateKind;
items: UserListItem[]; items: UserListItem[];
collaborators: UserListCollaborator[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

@@ -0,0 +1,3 @@
export class ShareListDto {
userId?: string;
}

View File

@@ -16,6 +16,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { CreateListDto } from './dto/create-list.dto'; import { CreateListDto } from './dto/create-list.dto';
import { AddListItemDto, UpdateListItemDto } from './dto/list-item.dto'; import { AddListItemDto, UpdateListItemDto } from './dto/list-item.dto';
import { ShareListDto } from './dto/share-list.dto';
import { UpdateListDto } from './dto/update-list.dto'; import { UpdateListDto } from './dto/update-list.dto';
import { ListRealtimeService } from './list-realtime.service'; import { ListRealtimeService } from './list-realtime.service';
import { ListsService } from './lists.service'; import { ListsService } from './lists.service';
@@ -78,6 +79,32 @@ export class ListsController {
return this.listsService.deleteList(this.requireUserId(request), listId); return this.listsService.deleteList(this.requireUserId(request), listId);
} }
@Post(':listId/shares')
shareList(
@Req() request: AuthenticatedRequest,
@Param('listId') listId: string,
@Body() shareDto: ShareListDto,
) {
return this.listsService.shareList(
this.requireUserId(request),
listId,
shareDto,
);
}
@Delete(':listId/shares/:userId')
removeShare(
@Req() request: AuthenticatedRequest,
@Param('listId') listId: string,
@Param('userId') userId: string,
) {
return this.listsService.removeShare(
this.requireUserId(request),
listId,
userId,
);
}
@Post(':listId/items') @Post(':listId/items')
addItem( addItem(
@Req() request: AuthenticatedRequest, @Req() request: AuthenticatedRequest,

View File

@@ -2,17 +2,24 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { UserEntity } from '../auth/user.entity';
import { ListsController } from './lists.controller'; import { ListsController } from './lists.controller';
import { ListRealtimeService } from './list-realtime.service'; import { ListRealtimeService } from './list-realtime.service';
import { ListsService } from './lists.service'; import { ListsService } from './lists.service';
import { UserListEntity } from './user-list.entity'; import { UserListEntity } from './user-list.entity';
import { UserListItemEntity } from './user-list-item.entity'; import { UserListItemEntity } from './user-list-item.entity';
import { UserListShareEntity } from './user-list-share.entity';
@Module({ @Module({
imports: [ imports: [
AuditModule, AuditModule,
AuthModule, AuthModule,
TypeOrmModule.forFeature([UserListEntity, UserListItemEntity]), TypeOrmModule.forFeature([
UserEntity,
UserListEntity,
UserListItemEntity,
UserListShareEntity,
]),
], ],
controllers: [ListsController], controllers: [ListsController],
providers: [ListRealtimeService, ListsService], providers: [ListRealtimeService, ListsService],

View File

@@ -1,18 +1,24 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { UserEntity } from '../auth/user.entity';
import { ListTemplate } from '../list-templates/list-template.types'; import { ListTemplate } from '../list-templates/list-template.types';
import { InMemoryRepository } from '../testing/in-memory-repository'; import { InMemoryRepository } from '../testing/in-memory-repository';
import { ListRealtimeService } from './list-realtime.service'; import { ListRealtimeService } from './list-realtime.service';
import { ListsService } from './lists.service'; import { ListsService } from './lists.service';
import { UserListEntity } from './user-list.entity'; import { UserListEntity } from './user-list.entity';
import { UserListItemEntity } from './user-list-item.entity'; import { UserListItemEntity } from './user-list-item.entity';
import { UserListShareEntity } from './user-list-share.entity';
describe('ListsService', () => { describe('ListsService', () => {
let service: ListsService; let service: ListsService;
let usersRepository: InMemoryRepository<UserEntity>;
beforeEach(() => { beforeEach(() => {
usersRepository = new InMemoryRepository<UserEntity>();
service = new ListsService( service = new ListsService(
new InMemoryRepository<UserListEntity>() as never, new InMemoryRepository<UserListEntity>() as never,
new InMemoryRepository<UserListItemEntity>() as never, new InMemoryRepository<UserListItemEntity>() as never,
new InMemoryRepository<UserListShareEntity>() as never,
usersRepository as never,
); );
}); });
@@ -57,6 +63,8 @@ describe('ListsService', () => {
service = new ListsService( service = new ListsService(
new InMemoryRepository<UserListEntity>() as never, new InMemoryRepository<UserListEntity>() as never,
new InMemoryRepository<UserListItemEntity>() as never, new InMemoryRepository<UserListItemEntity>() as never,
new InMemoryRepository<UserListShareEntity>() as never,
usersRepository as never,
undefined, undefined,
realtimeService as never, realtimeService as never,
); );
@@ -100,6 +108,41 @@ describe('ListsService', () => {
); );
}); });
it('allows owners to share lists with collaborators', async () => {
await usersRepository.save({
id: 'user-2',
email: 'collaborator@example.com',
passwordHash: 'hash',
verified: true,
onboardingCompleted: false,
createdAt: new Date(),
updatedAt: new Date(),
});
const list = await service.createList('user-1', {
name: 'Geteilte Liste',
});
const sharedList = await service.shareList('user-1', list.id, {
userId: 'user-2',
});
const collaboratorList = await service.getList('user-2', list.id);
const updatedByCollaborator = await service.addItem('user-2', list.id, {
title: 'Gemeinsamer Eintrag',
});
expect(sharedList.collaborators).toEqual([
expect.objectContaining({
id: 'user-2',
email: 'collaborator@example.com',
}),
]);
expect(collaboratorList.accessRole).toBe('collaborator');
expect(updatedByCollaborator.items).toHaveLength(1);
await expect(service.deleteList('user-2', list.id)).rejects.toThrow(
ForbiddenException,
);
});
it('adds, updates, checks and deletes list items', async () => { it('adds, updates, checks and deletes list items', async () => {
const list = await service.createList('user-1', { const list = await service.createList('user-1', {
name: 'Einkauf', name: 'Einkauf',

View File

@@ -13,15 +13,19 @@ import {
ListTemplate, ListTemplate,
ListTemplateKind, ListTemplateKind,
UserList, UserList,
UserListAccessRole,
UserListItem, UserListItem,
} from '../list-templates/list-template.types'; } from '../list-templates/list-template.types';
import { UserEntity } from '../auth/user.entity';
import { CreateListFromTemplateDto } from '../list-templates/dto/create-list-from-template.dto'; import { CreateListFromTemplateDto } from '../list-templates/dto/create-list-from-template.dto';
import { AddListItemDto, UpdateListItemDto } from './dto/list-item.dto'; import { AddListItemDto, UpdateListItemDto } from './dto/list-item.dto';
import { CreateListDto } from './dto/create-list.dto'; import { CreateListDto } from './dto/create-list.dto';
import { ListRealtimeService } from './list-realtime.service'; import { ListRealtimeService } from './list-realtime.service';
import { ShareListDto } from './dto/share-list.dto';
import { UpdateListDto } from './dto/update-list.dto'; import { UpdateListDto } from './dto/update-list.dto';
import { UserListEntity } from './user-list.entity'; import { UserListEntity } from './user-list.entity';
import { UserListItemEntity } from './user-list-item.entity'; import { UserListItemEntity } from './user-list-item.entity';
import { UserListShareEntity } from './user-list-share.entity';
@Injectable() @Injectable()
export class ListsService { export class ListsService {
@@ -30,6 +34,10 @@ export class ListsService {
private readonly listsRepository: Repository<UserListEntity>, private readonly listsRepository: Repository<UserListEntity>,
@InjectRepository(UserListItemEntity) @InjectRepository(UserListItemEntity)
private readonly listItemsRepository: Repository<UserListItemEntity>, private readonly listItemsRepository: Repository<UserListItemEntity>,
@InjectRepository(UserListShareEntity)
private readonly listSharesRepository: Repository<UserListShareEntity>,
@InjectRepository(UserEntity)
private readonly usersRepository: Repository<UserEntity>,
@Optional() @Optional()
private readonly auditLogService?: AuditLogService, private readonly auditLogService?: AuditLogService,
@Optional() @Optional()
@@ -59,8 +67,8 @@ export class ListsService {
}, },
}); });
const userList = this.toUserList(savedList); const userList = await this.getList(ownerId, savedList.id);
this.listRealtimeService?.publishSnapshot(ownerId, userList); await this.publishListSnapshot(savedList.id);
return userList; return userList;
} }
@@ -114,24 +122,51 @@ export class ListsService {
}, },
}); });
const userList = this.toUserList(savedList); const userList = await this.getList(ownerId, savedList.id);
this.listRealtimeService?.publishSnapshot(ownerId, userList); await this.publishListSnapshot(savedList.id);
return userList; return userList;
} }
async listLists(ownerId: string): Promise<UserList[]> { async listLists(ownerId: string): Promise<UserList[]> {
const lists = await this.listsRepository.find({ const ownedLists = await this.listsRepository.find({
where: { ownerId }, where: { ownerId },
relations: { items: true }, relations: { items: true, owner: true, shares: { user: true } },
order: { name: 'ASC', items: { position: 'ASC' } }, order: { name: 'ASC', items: { position: 'ASC' } },
}); });
const sharedListShares = await this.listSharesRepository.find({
where: { userId: ownerId },
relations: { list: { items: true, owner: true, shares: { user: true } } },
});
const listsById = new Map<string, UserListEntity>();
return lists.map((list) => this.toUserList(list)); for (const list of ownedLists) {
await this.hydrateListAccessRelations(list);
listsById.set(list.id, list);
}
for (const share of sharedListShares) {
const sharedList =
share.list ??
(await this.listsRepository.findOne({
where: { id: share.listId },
relations: { items: true, owner: true, shares: { user: true } },
order: { items: { position: 'ASC' } },
}));
if (sharedList) {
await this.hydrateListAccessRelations(sharedList);
listsById.set(sharedList.id, sharedList);
}
}
return [...listsById.values()]
.sort((left, right) => left.name.localeCompare(right.name))
.map((list) => this.toUserList(list, ownerId));
} }
async getList(ownerId: string, listId: string): Promise<UserList> { async getList(ownerId: string, listId: string): Promise<UserList> {
return this.toUserList(await this.findOwnedList(ownerId, listId)); return this.toUserList(await this.findAccessibleList(ownerId, listId), ownerId);
} }
async updateList( async updateList(
@@ -139,7 +174,7 @@ export class ListsService {
listId: string, listId: string,
updateDto: UpdateListDto, updateDto: UpdateListDto,
): Promise<UserList> { ): Promise<UserList> {
const list = await this.findOwnedList(ownerId, listId); const list = await this.findAccessibleList(ownerId, listId);
if (updateDto.name !== undefined) { if (updateDto.name !== undefined) {
list.name = this.requireName(updateDto.name); list.name = this.requireName(updateDto.name);
@@ -169,14 +204,15 @@ export class ListsService {
}, },
}); });
const userList = this.toUserList(savedList); const userList = await this.getList(ownerId, savedList.id);
this.listRealtimeService?.publishSnapshot(ownerId, userList); await this.publishListSnapshot(savedList.id);
return userList; return userList;
} }
async deleteList(ownerId: string, listId: string): Promise<{ message: string }> { async deleteList(ownerId: string, listId: string): Promise<{ message: string }> {
const list = await this.findOwnedList(ownerId, listId); const list = await this.findOwnedList(ownerId, listId);
const accessorIds = this.listAccessorIds(list);
const metadata = { const metadata = {
name: list.name, name: list.name,
kind: list.kind, kind: list.kind,
@@ -192,17 +228,100 @@ export class ListsService {
metadata, metadata,
}); });
this.listRealtimeService?.publishDeleted(ownerId, listId); accessorIds.forEach((accessorId) =>
this.listRealtimeService?.publishDeleted(accessorId, listId),
);
return { message: 'List deleted.' }; return { message: 'List deleted.' };
} }
async shareList(
ownerId: string,
listId: string,
shareDto: ShareListDto,
): Promise<UserList> {
const list = await this.findOwnedList(ownerId, listId);
const targetUserId = this.requireShareUserId(shareDto.userId);
if (targetUserId === ownerId) {
throw new BadRequestException('List owner cannot be added as collaborator.');
}
const targetUser = await this.usersRepository.findOne({
where: { id: targetUserId },
});
if (!targetUser || !targetUser.verified) {
throw new NotFoundException('User was not found.');
}
const existingShare = await this.listSharesRepository.findOne({
where: { listId, userId: targetUserId },
});
if (!existingShare) {
await this.listSharesRepository.save(
this.listSharesRepository.create({
id: randomUUID(),
listId,
userId: targetUserId,
role: 'collaborator',
}),
);
}
await this.auditLogService?.record({
actorUserId: ownerId,
action: 'list.shared',
entityType: 'list',
entityId: list.id,
metadata: {
sharedWithUserId: targetUserId,
sharedWithEmail: targetUser.email,
},
});
await this.publishListSnapshot(listId);
return this.getList(ownerId, listId);
}
async removeShare(
ownerId: string,
listId: string,
collaboratorUserId: string,
): Promise<UserList> {
const list = await this.findOwnedList(ownerId, listId);
const existingShare = await this.listSharesRepository.findOne({
where: { listId, userId: collaboratorUserId },
});
if (!existingShare) {
throw new NotFoundException('List share was not found.');
}
await this.listSharesRepository.remove(existingShare);
await this.auditLogService?.record({
actorUserId: ownerId,
action: 'list.unshared',
entityType: 'list',
entityId: list.id,
metadata: { removedUserId: collaboratorUserId },
});
this.listRealtimeService?.publishDeleted(collaboratorUserId, listId);
await this.publishListSnapshot(listId);
return this.getList(ownerId, listId);
}
async addItem( async addItem(
ownerId: string, ownerId: string,
listId: string, listId: string,
addDto: AddListItemDto, addDto: AddListItemDto,
): Promise<UserList> { ): Promise<UserList> {
const list = await this.findOwnedList(ownerId, listId); const list = await this.findAccessibleList(ownerId, listId);
const item = this.createListItem(addDto, list.items.length); const item = this.createListItem(addDto, list.items.length);
item.listId = list.id; item.listId = list.id;
@@ -224,7 +343,7 @@ export class ListsService {
}); });
const updatedList = await this.getList(ownerId, listId); const updatedList = await this.getList(ownerId, listId);
this.listRealtimeService?.publishSnapshot(ownerId, updatedList); await this.publishListSnapshot(listId);
return updatedList; return updatedList;
} }
@@ -236,7 +355,7 @@ export class ListsService {
updateDto: UpdateListItemDto, updateDto: UpdateListItemDto,
actorName?: string, actorName?: string,
): Promise<UserList> { ): Promise<UserList> {
const list = await this.findOwnedList(ownerId, listId); const list = await this.findAccessibleList(ownerId, listId);
const item = this.findListItem(list, itemId); const item = this.findListItem(list, itemId);
const wasChecked = item.checked; const wasChecked = item.checked;
@@ -297,7 +416,7 @@ export class ListsService {
}); });
const updatedList = await this.getList(ownerId, listId); const updatedList = await this.getList(ownerId, listId);
this.listRealtimeService?.publishSnapshot(ownerId, updatedList); await this.publishListSnapshot(listId);
return updatedList; return updatedList;
} }
@@ -307,7 +426,7 @@ export class ListsService {
listId: string, listId: string,
itemId: string, itemId: string,
): Promise<UserList> { ): Promise<UserList> {
const list = await this.findOwnedList(ownerId, listId); const list = await this.findAccessibleList(ownerId, listId);
const itemIndex = list.items.findIndex((item) => item.id === itemId); const itemIndex = list.items.findIndex((item) => item.id === itemId);
if (itemIndex === -1) { if (itemIndex === -1) {
@@ -336,18 +455,18 @@ export class ListsService {
}); });
const updatedList = await this.getList(ownerId, listId); const updatedList = await this.getList(ownerId, listId);
this.listRealtimeService?.publishSnapshot(ownerId, updatedList); await this.publishListSnapshot(listId);
return updatedList; return updatedList;
} }
private async findOwnedList( private async findAccessibleList(
ownerId: string, ownerId: string,
listId: string, listId: string,
): Promise<UserListEntity> { ): Promise<UserListEntity> {
const list = await this.listsRepository.findOne({ const list = await this.listsRepository.findOne({
where: { id: listId }, where: { id: listId },
relations: { items: true }, relations: { items: true, owner: true, shares: { user: true } },
order: { items: { position: 'ASC' } }, order: { items: { position: 'ASC' } },
}); });
@@ -355,11 +474,27 @@ export class ListsService {
throw new NotFoundException('List was not found.'); throw new NotFoundException('List was not found.');
} }
if (list.ownerId !== ownerId) { await this.hydrateListAccessRelations(list);
if (!this.canAccessList(list, ownerId)) {
throw new ForbiddenException('List belongs to another user.'); throw new ForbiddenException('List belongs to another user.');
} }
list.items = list.items ?? []; list.items = list.items ?? [];
list.shares = list.shares ?? [];
return list;
}
private async findOwnedList(
ownerId: string,
listId: string,
): Promise<UserListEntity> {
const list = await this.findAccessibleList(ownerId, listId);
if (list.ownerId !== ownerId) {
throw new ForbiddenException('Only the list owner can perform this action.');
}
return list; return list;
} }
@@ -465,10 +600,87 @@ export class ListsService {
return value; return value;
} }
private toUserList(list: UserListEntity): UserList { private requireShareUserId(userId?: string): string {
const normalizedUserId = userId?.trim();
if (!normalizedUserId) {
throw new BadRequestException('User id is required.');
}
return normalizedUserId;
}
private canAccessList(list: UserListEntity, userId: string): boolean {
return (
list.ownerId === userId ||
Boolean(list.shares?.some((share) => share.userId === userId))
);
}
private accessRoleFor(
list: UserListEntity,
viewerId?: string,
): UserListAccessRole {
return list.ownerId === viewerId ? 'owner' : 'collaborator';
}
private listAccessorIds(list: UserListEntity): string[] {
return [
list.ownerId,
...(list.shares ?? []).map((share) => share.userId),
].filter((userId, index, userIds) => userIds.indexOf(userId) === index);
}
private async hydrateListAccessRelations(list: UserListEntity): Promise<void> {
list.owner ??= (await this.usersRepository.findOne({
where: { id: list.ownerId },
})) ?? undefined;
const storedShares = await this.listSharesRepository.find({
where: { listId: list.id },
relations: { user: true },
});
list.shares = storedShares;
for (const share of list.shares) {
share.user ??= (await this.usersRepository.findOne({
where: { id: share.userId },
})) ?? undefined;
}
}
private async publishListSnapshot(listId: string): Promise<void> {
if (!this.listRealtimeService) {
return;
}
const list = await this.listsRepository.findOne({
where: { id: listId },
relations: { items: true, owner: true, shares: { user: true } },
order: { items: { position: 'ASC' } },
});
if (!list) {
return;
}
await this.hydrateListAccessRelations(list);
this.listAccessorIds(list).forEach((accessorId) => {
this.listRealtimeService?.publishSnapshot(
accessorId,
this.toUserList(list, accessorId),
);
});
}
private toUserList(list: UserListEntity, viewerId?: string): UserList {
return { return {
id: list.id, id: list.id,
ownerId: list.ownerId, ownerId: list.ownerId,
ownerName: list.owner?.name ?? undefined,
ownerEmail: list.owner?.email ?? undefined,
accessRole: this.accessRoleFor(list, viewerId),
sourceTemplateId: list.sourceTemplateId ?? undefined, sourceTemplateId: list.sourceTemplateId ?? undefined,
name: list.name, name: list.name,
description: list.description ?? undefined, description: list.description ?? undefined,
@@ -476,6 +688,14 @@ export class ListsService {
items: (list.items ?? []) items: (list.items ?? [])
.sort((left, right) => left.position - right.position) .sort((left, right) => left.position - right.position)
.map((item) => this.toUserListItem(item)), .map((item) => this.toUserListItem(item)),
collaborators: (list.shares ?? [])
.filter((share) => Boolean(share.user))
.map((share) => ({
id: share.userId,
name: share.user?.name ?? undefined,
email: share.user!.email,
role: 'collaborator',
})),
createdAt: this.toIsoString(list.createdAt), createdAt: this.toIsoString(list.createdAt),
updatedAt: this.toIsoString(list.updatedAt), updatedAt: this.toIsoString(list.updatedAt),
}; };

View File

@@ -0,0 +1,50 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
} from 'typeorm';
import { UserEntity } from '../auth/user.entity';
import { UserListEntity } from './user-list.entity';
export type UserListShareRole = 'collaborator';
@Entity('user_list_shares')
@Index(['listId', 'userId'], { unique: true })
export class UserListShareEntity {
@PrimaryColumn({ type: 'varchar', length: 36 })
id!: string;
@Index()
@Column({ type: 'varchar', length: 36 })
listId!: string;
@Index()
@Column({ type: 'varchar', length: 36 })
userId!: string;
@Column({ type: 'varchar', length: 32, default: 'collaborator' })
role!: UserListShareRole;
@CreateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
})
createdAt!: Date;
@ManyToOne(() => UserListEntity, (list) => list.shares, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'listId' })
list?: UserListEntity;
@ManyToOne(() => UserEntity, (user) => user.sharedLists, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'userId' })
user?: UserEntity;
}

View File

@@ -10,6 +10,7 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { UserEntity } from '../auth/user.entity'; import { UserEntity } from '../auth/user.entity';
import { UserListShareEntity } from './user-list-share.entity';
import { UserListItemEntity } from './user-list-item.entity'; import { UserListItemEntity } from './user-list-item.entity';
import type { ListTemplateKind } from '../list-templates/list-template.types'; import type { ListTemplateKind } from '../list-templates/list-template.types';
@@ -59,4 +60,7 @@ export class UserListEntity {
cascade: ['insert', 'update'], cascade: ['insert', 'update'],
}) })
items!: UserListItemEntity[]; items!: UserListItemEntity[];
@OneToMany(() => UserListShareEntity, (share) => share.list)
shares?: UserListShareEntity[];
} }

View File

@@ -67,5 +67,11 @@ function isApiRequest(request: HttpRequest<unknown>): boolean {
} }
function isAuthRequest(request: HttpRequest<unknown>): boolean { function isAuthRequest(request: HttpRequest<unknown>): boolean {
return request.url.startsWith('/api/auth/'); return [
'/api/auth/login',
'/api/auth/register',
'/api/auth/refresh',
'/api/auth/resend-verification',
'/api/auth/verify-email',
].some((publicAuthUrl) => request.url.startsWith(publicAuthUrl));
} }

View File

@@ -6,6 +6,12 @@ export interface PublicUser {
onboardingCompleted: boolean; onboardingCompleted: boolean;
} }
export interface PublicUserSearchResult {
id: string;
email: string;
name?: string;
}
export interface AuthTokenResponse { export interface AuthTokenResponse {
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;

View File

@@ -5,6 +5,7 @@ import {
AuthTokenResponse, AuthTokenResponse,
LoginRequest, LoginRequest,
PublicUser, PublicUser,
PublicUserSearchResult,
RegisterRequest, RegisterRequest,
RegisterResponse, RegisterResponse,
ResendVerificationResponse, ResendVerificationResponse,
@@ -53,6 +54,13 @@ export class AuthService {
.pipe(tap((user) => this.storeUser(user))); .pipe(tap((user) => this.storeUser(user)));
} }
searchUsers(query: string): Observable<PublicUserSearchResult[]> {
const params = new HttpParams().set('q', query);
return this.http.get<PublicUserSearchResult[]>(`${this.apiUrl}/users/search`, {
params,
});
}
updateOnboardingCompleted(completed: boolean): Observable<PublicUser> { updateOnboardingCompleted(completed: boolean): Observable<PublicUser> {
return this.http return this.http
.patch<PublicUser>(`${this.apiUrl}/me/onboarding`, { completed }) .patch<PublicUser>(`${this.apiUrl}/me/onboarding`, { completed })

View File

@@ -8,7 +8,12 @@
@if (isCreateMode()) { @if (isCreateMode()) {
<p>Liste anlegen</p> <p>Liste anlegen</p>
} @else if (list()) { } @else if (list()) {
<p>{{ checkedCount(list()!) }} / {{ list()!.items.length }} erledigt</p> <p>
{{ checkedCount(list()!) }} / {{ list()!.items.length }} erledigt
@if (list()!.accessRole === 'collaborator') {
- geteilt von {{ list()!.ownerName || list()!.ownerEmail || 'Owner' }}
}
</p>
} }
</div> </div>
</header> </header>
@@ -77,6 +82,104 @@
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@if (list() && (showEditor() || list()!.collaborators.length > 0 || list()!.accessRole === 'collaborator')) {
<mat-card class="sharing-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Freigaben</mat-card-title>
<mat-card-subtitle>
@if (canManageShares()) {
{{ list()!.collaborators.length }} Mitwirkende
} @else {
Geteilt von {{ list()!.ownerName || list()!.ownerEmail || 'Owner' }}
}
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
@if (showShareControls()) {
<mat-form-field appearance="outline" class="share-search-field">
<mat-label>User suchen</mat-label>
<mat-icon matPrefix aria-hidden="true">person_search</mat-icon>
<input
matInput
type="search"
[value]="shareSearchTerm()"
(input)="searchShareUsers($any($event.target).value)"
autocomplete="off"
/>
@if (searchingUsers()) {
<mat-progress-spinner matSuffix mode="indeterminate" diameter="18" />
}
</mat-form-field>
@if (availableShareSearchResults().length > 0) {
<ul class="share-results">
@for (user of availableShareSearchResults(); track user.id) {
<li>
<span>{{ displayUser(user) }}</span>
<button
mat-stroked-button
type="button"
[disabled]="sharingUserId() === user.id"
(click)="shareWithUser(user)"
>
@if (sharingUserId() === user.id) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">person_add</mat-icon>
}
Hinzufuegen
</button>
</li>
}
</ul>
} @else if (shareSearchTerm().trim().length >= 2 && !searchingUsers()) {
<div class="inline-empty">
<mat-icon aria-hidden="true">person_off</mat-icon>
<span>Keine passenden User gefunden.</span>
</div>
}
}
@if (list()!.collaborators.length > 0) {
<ul class="collaborator-list">
@for (collaborator of list()!.collaborators; track collaborator.id) {
<li>
<div>
<strong>{{ collaborator.name || collaborator.email }}</strong>
@if (collaborator.name) {
<span>{{ collaborator.email }}</span>
}
</div>
@if (showShareControls()) {
<button
mat-icon-button
type="button"
[attr.aria-label]="displayUser(collaborator) + ' entfernen'"
[disabled]="removingShareUserId() === collaborator.id"
(click)="removeCollaborator(collaborator.id)"
>
@if (removingShareUserId() === collaborator.id) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">person_remove</mat-icon>
}
</button>
}
</li>
}
</ul>
} @else {
<div class="inline-empty">
<mat-icon aria-hidden="true">group</mat-icon>
<span>Noch keine Mitwirkenden.</span>
</div>
}
</mat-card-content>
</mat-card>
}
<mat-card class="items-card" appearance="outlined"> <mat-card class="items-card" appearance="outlined">
<mat-card-header> <mat-card-header>
<mat-card-title>Items</mat-card-title> <mat-card-title>Items</mat-card-title>

View File

@@ -34,6 +34,7 @@
.state-card, .state-card,
.editor-card, .editor-card,
.sharing-card,
.items-card { .items-card {
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
@@ -50,6 +51,56 @@
gap: 0.75rem; gap: 0.75rem;
} }
.share-search-field {
width: 100%;
}
.share-results,
.collaborator-list {
display: grid;
gap: 0.5rem;
margin: 0.75rem 0 0;
padding: 0;
list-style: none;
}
.share-results li,
.collaborator-list li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
min-width: 0;
padding: 0.6rem;
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface));
}
.share-results span,
.collaborator-list strong,
.collaborator-list span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collaborator-list div {
display: grid;
min-width: 0;
}
.collaborator-list span {
color: var(--mat-sys-on-surface-variant);
font-size: 0.85rem;
}
.share-results mat-progress-spinner,
.collaborator-list mat-progress-spinner {
display: inline-flex;
margin-right: 0.5rem;
}
.editor-card mat-card-header button { .editor-card mat-card-header button {
flex: 0 0 auto; flex: 0 0 auto;
} }

View File

@@ -12,7 +12,9 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AuthService } from '../../auth/auth.service';
import { getAuthErrorMessage } from '../../auth/error-message'; import { getAuthErrorMessage } from '../../auth/error-message';
import { PublicUserSearchResult } from '../../auth/auth.models';
import { OnboardingService } from '../../onboarding/onboarding.service'; import { OnboardingService } from '../../onboarding/onboarding.service';
import { ListRealtimeEvent, UserList, UserListItem } from '../lists.models'; import { ListRealtimeEvent, UserList, UserListItem } from '../lists.models';
import { ListsRealtimeService } from '../lists-realtime.service'; import { ListsRealtimeService } from '../lists-realtime.service';
@@ -39,6 +41,7 @@ import { ListsService } from '../lists.service';
export class ListDetailComponent implements OnInit { export class ListDetailComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(NonNullableFormBuilder); private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly authService = inject(AuthService);
private readonly listsService = inject(ListsService); private readonly listsService = inject(ListsService);
private readonly listsRealtimeService = inject(ListsRealtimeService); private readonly listsRealtimeService = inject(ListsRealtimeService);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
@@ -54,7 +57,28 @@ export class ListDetailComponent implements OnInit {
protected readonly addingItem = signal(false); protected readonly addingItem = signal(false);
protected readonly errorMessage = signal<string | null>(null); protected readonly errorMessage = signal<string | null>(null);
protected readonly updatingItemId = signal<string | null>(null); protected readonly updatingItemId = signal<string | null>(null);
protected readonly shareSearchTerm = signal('');
protected readonly shareSearchResults = signal<PublicUserSearchResult[]>([]);
protected readonly searchingUsers = signal(false);
protected readonly sharingUserId = signal<string | null>(null);
protected readonly removingShareUserId = signal<string | null>(null);
protected readonly canEditItems = computed(() => Boolean(this.list()?.id)); protected readonly canEditItems = computed(() => Boolean(this.list()?.id));
protected readonly canManageShares = computed(
() => this.list()?.accessRole === 'owner' && !this.isCreateMode(),
);
protected readonly showShareControls = computed(
() => this.canManageShares() && this.showEditor(),
);
protected readonly availableShareSearchResults = computed(() => {
const list = this.list();
const collaboratorIds = new Set(
list?.collaborators.map((collaborator) => collaborator.id) ?? [],
);
return this.shareSearchResults().filter(
(user) => user.id !== list?.ownerId && !collaboratorIds.has(user.id),
);
});
protected readonly showEditor = computed(() => this.isCreateMode() || this.editing()); protected readonly showEditor = computed(() => this.isCreateMode() || this.editing());
protected readonly listForm = this.formBuilder.group({ protected readonly listForm = this.formBuilder.group({
@@ -234,6 +258,77 @@ export class ListDetailComponent implements OnInit {
return list.items.filter((item) => item.checked).length; return list.items.filter((item) => item.checked).length;
} }
protected searchShareUsers(term: string): void {
this.shareSearchTerm.set(term);
if (term.trim().length < 2) {
this.shareSearchResults.set([]);
return;
}
this.searchingUsers.set(true);
this.authService
.searchUsers(term)
.pipe(finalize(() => this.searchingUsers.set(false)))
.subscribe({
next: (users) => this.shareSearchResults.set(users),
error: (error: unknown) => {
this.shareSearchResults.set([]);
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected shareWithUser(user: PublicUserSearchResult): void {
const listId = this.listId();
if (!listId || this.sharingUserId()) {
return;
}
this.sharingUserId.set(user.id);
this.listsService
.shareList(listId, user.id)
.pipe(finalize(() => this.sharingUserId.set(null)))
.subscribe({
next: (list) => {
this.setList(list, !this.showEditor());
this.shareSearchTerm.set('');
this.shareSearchResults.set([]);
this.snackBar.open('Liste geteilt.', 'OK', { duration: 2500 });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected removeCollaborator(userId: string): void {
const listId = this.listId();
if (!listId || this.removingShareUserId()) {
return;
}
this.removingShareUserId.set(userId);
this.listsService
.removeShare(listId, userId)
.pipe(finalize(() => this.removingShareUserId.set(null)))
.subscribe({
next: (list) => {
this.setList(list, !this.showEditor());
this.snackBar.open('Freigabe entfernt.', 'OK', { duration: 2500 });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected displayUser(user: { name?: string; email: string }): string {
return user.name ? `${user.name} (${user.email})` : user.email;
}
protected async backToLists(): Promise<void> { protected async backToLists(): Promise<void> {
await this.router.navigateByUrl('/lists'); await this.router.navigateByUrl('/lists');
} }

View File

@@ -124,6 +124,9 @@
<mat-card-title>{{ list.name }}</mat-card-title> <mat-card-title>{{ list.name }}</mat-card-title>
<mat-card-subtitle> <mat-card-subtitle>
{{ kindLabel(list.kind) }} - {{ progressLabel(list) }} {{ kindLabel(list.kind) }} - {{ progressLabel(list) }}
@if (list.accessRole === 'collaborator') {
- geteilt von {{ list.ownerName || list.ownerEmail || 'Owner' }}
}
</mat-card-subtitle> </mat-card-subtitle>
</mat-card-header> </mat-card-header>
@@ -141,6 +144,12 @@
<mat-icon aria-hidden="true">schedule</mat-icon> <mat-icon aria-hidden="true">schedule</mat-icon>
{{ list.updatedAt | date: 'dd.MM.yyyy' }} {{ list.updatedAt | date: 'dd.MM.yyyy' }}
</span> </span>
@if (list.collaborators.length > 0) {
<span>
<mat-icon aria-hidden="true">group</mat-icon>
{{ list.collaborators.length }} Mitwirkende
</span>
}
</div> </div>
@if (list.items.length > 0) { @if (list.items.length > 0) {

View File

@@ -16,14 +16,27 @@ export interface UserListItem {
updatedAt: string; updatedAt: string;
} }
export type UserListAccessRole = 'owner' | 'collaborator';
export interface UserListCollaborator {
id: string;
name?: string;
email: string;
role: 'collaborator';
}
export interface UserList { export interface UserList {
id: string; id: string;
ownerId: string; ownerId: string;
ownerName?: string;
ownerEmail?: string;
accessRole: UserListAccessRole;
sourceTemplateId?: string; sourceTemplateId?: string;
name: string; name: string;
description?: string; description?: string;
kind: ListTemplateKind; kind: ListTemplateKind;
items: UserListItem[]; items: UserListItem[];
collaborators: UserListCollaborator[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

@@ -30,6 +30,14 @@ export class ListsService {
return this.http.patch<UserList>(`${this.apiUrl}/${listId}`, data); return this.http.patch<UserList>(`${this.apiUrl}/${listId}`, data);
} }
shareList(listId: string, userId: string): Observable<UserList> {
return this.http.post<UserList>(`${this.apiUrl}/${listId}/shares`, { userId });
}
removeShare(listId: string, userId: string): Observable<UserList> {
return this.http.delete<UserList>(`${this.apiUrl}/${listId}/shares/${userId}`);
}
addItem(listId: string, data: AddListItemRequest): Observable<UserList> { addItem(listId: string, data: AddListItemRequest): Observable<UserList> {
return this.http.post<UserList>(`${this.apiUrl}/${listId}/items`, data); return this.http.post<UserList>(`${this.apiUrl}/${listId}/items`, data);
} }