collab
This commit is contained in:
@@ -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'
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -37,3 +37,9 @@ export interface PublicUser {
|
|||||||
verified: boolean;
|
verified: boolean;
|
||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicUserSearchResult {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
3
listify-api/src/lists/dto/share-list.dto.ts
Normal file
3
listify-api/src/lists/dto/share-list.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export class ShareListDto {
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
50
listify-api/src/lists/user-list-share.entity.ts
Normal file
50
listify-api/src/lists/user-list-share.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user