alpha
Login
or
Join now
nandi.uk
/
semble
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
This repository has no description
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
Pulls
Pipelines
wip: unifying response types
author
Wesley Finck
date
9 months ago
(Oct 20, 2025, 4:31 PM -0700)
commit
172884de
172884de7291daf635d44095a51127c0b38908db
parent
729a0ea1
729a0ea12e92d7140f7c815905dc9db3b434c53f
+485
-207
13 changed files
Expand all
Collapse all
Unified
Split
src
modules
cards
application
useCases
queries
GetCollectionsForUrlUseCase.ts
GetCollectionsUseCase.ts
GetLibrariesForCardUseCase.ts
GetLibrariesForUrlUseCase.ts
GetNoteCardsForUrlUseCase.ts
GetUrlCardViewUseCase.ts
GetUrlStatusForMyLibraryUseCase.ts
domain
ICardQueryRepository.ts
infrastructure
repositories
mappers
CardMapper.ts
query-services
CollectionCardQueryService.ts
UrlCardQueryService.ts
tests
infrastructure
DrizzleCardQueryRepository.getLibrariesForUrl.integration.test.ts
webapp
api-client
types
responses.ts
+30
-5
src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts
View file
Reviewed
···
7
7
} from '../../../domain/ICollectionQueryRepository';
8
8
import { URL } from '../../../domain/value-objects/URL';
9
9
import { IProfileService } from '../../../domain/services/IProfileService';
10
10
+
import { ICollectionRepository } from '../../../domain/ICollectionRepository';
11
11
+
import { CollectionId } from '../../../domain/value-objects/CollectionId';
10
12
11
13
export interface GetCollectionsForUrlQuery {
12
14
url: string;
···
27
29
name: string;
28
30
handle: string;
29
31
avatarUrl?: string;
32
32
+
description?: string;
30
33
};
34
34
+
cardCount: number;
35
35
+
createdAt: string;
36
36
+
updatedAt: string;
31
37
}
32
38
33
39
export interface GetCollectionsForUrlResult {
···
59
65
constructor(
60
66
private collectionQueryRepo: ICollectionQueryRepository,
61
67
private profileService: IProfileService,
68
68
+
private collectionRepo: ICollectionRepository,
62
69
) {}
63
70
64
71
async execute(
···
104
111
// Create a map of profiles
105
112
const profileMap = new Map<
106
113
string,
107
107
-
{ id: string; name: string; handle: string; avatarUrl?: string }
114
114
+
{ id: string; name: string; handle: string; avatarUrl?: string; description?: string }
108
115
>();
109
116
110
117
for (let i = 0; i < uniqueAuthorIds.length; i++) {
···
126
133
name: profile.name,
127
134
handle: profile.handle,
128
135
avatarUrl: profile.avatarUrl,
136
136
+
description: profile.bio,
129
137
});
130
138
}
131
139
132
132
-
// Map items with enriched author data
133
133
-
const enrichedCollections: CollectionForUrlDTO[] = result.items.map(
134
134
-
(item) => {
140
140
+
// Map items with enriched author data and full collection data
141
141
+
const enrichedCollections: CollectionForUrlDTO[] = await Promise.all(
142
142
+
result.items.map(async (item) => {
135
143
const author = profileMap.get(item.authorId);
136
144
if (!author) {
137
145
throw new Error(`Profile not found for author ${item.authorId}`);
138
146
}
147
147
+
148
148
+
// Fetch full collection to get cardCount, dates
149
149
+
const collectionIdResult = CollectionId.createFromString(item.id);
150
150
+
if (collectionIdResult.isErr()) {
151
151
+
throw new Error(`Invalid collection ID: ${item.id}`);
152
152
+
}
153
153
+
const collectionResult = await this.collectionRepo.findById(
154
154
+
collectionIdResult.value,
155
155
+
);
156
156
+
if (collectionResult.isErr()) {
157
157
+
throw new Error(`Collection not found: ${item.id}`);
158
158
+
}
159
159
+
const collection = collectionResult.value;
160
160
+
139
161
return {
140
162
id: item.id,
141
163
uri: item.uri,
142
164
name: item.name,
143
165
description: item.description,
144
166
author,
167
167
+
cardCount: collection.cardCount,
168
168
+
createdAt: collection.createdAt.toISOString(),
169
169
+
updatedAt: collection.updatedAt.toISOString(),
145
170
};
146
146
-
},
171
171
+
}),
147
172
);
148
173
149
174
return ok({
+4
-2
src/modules/cards/application/useCases/queries/GetCollectionsUseCase.ts
View file
Reviewed
···
27
27
updatedAt: Date;
28
28
createdAt: Date;
29
29
cardCount: number;
30
30
-
createdBy: {
30
30
+
author: {
31
31
id: string;
32
32
name: string;
33
33
handle: string;
34
34
avatarUrl?: string;
35
35
+
description?: string;
35
36
};
36
37
}
37
38
export interface GetCollectionsResult {
···
127
128
updatedAt: item.updatedAt,
128
129
createdAt: item.createdAt,
129
130
cardCount: item.cardCount,
130
130
-
createdBy: {
131
131
+
author: {
131
132
id: profile.id,
132
133
name: profile.name,
133
134
handle: profile.handle,
134
135
avatarUrl: profile.avatarUrl,
136
136
+
description: profile.bio,
135
137
},
136
138
};
137
139
},
+2
src/modules/cards/application/useCases/queries/GetLibrariesForCardUseCase.ts
View file
Reviewed
···
12
12
name: string;
13
13
handle: string;
14
14
avatarUrl?: string;
15
15
+
description?: string;
15
16
}
16
17
17
18
export interface GetLibrariesForCardResult {
···
74
75
name: profile.name,
75
76
handle: profile.handle,
76
77
avatarUrl: profile.avatarUrl,
78
78
+
description: profile.bio,
77
79
});
78
80
} else {
79
81
errors.push(
+110
-4
src/modules/cards/application/useCases/queries/GetLibrariesForUrlUseCase.ts
View file
Reviewed
···
4
4
ICardQueryRepository,
5
5
CardSortField,
6
6
SortOrder,
7
7
-
LibraryForUrlDTO,
8
7
} from '../../../domain/ICardQueryRepository';
9
8
import { URL } from '../../../domain/value-objects/URL';
9
9
+
import { IProfileService } from '../../../domain/services/IProfileService';
10
10
11
11
export interface GetLibrariesForUrlQuery {
12
12
url: string;
13
13
+
callingUserId?: string;
13
14
page?: number;
14
15
limit?: number;
15
16
sortBy?: CardSortField;
16
17
sortOrder?: SortOrder;
17
18
}
18
19
20
20
+
export interface UserDTO {
21
21
+
id: string;
22
22
+
name: string;
23
23
+
handle: string;
24
24
+
avatarUrl?: string;
25
25
+
description?: string;
26
26
+
}
27
27
+
28
28
+
export interface UrlCardDTO {
29
29
+
id: string;
30
30
+
type: 'URL';
31
31
+
url: string;
32
32
+
cardContent: {
33
33
+
url: string;
34
34
+
title?: string;
35
35
+
description?: string;
36
36
+
author?: string;
37
37
+
thumbnailUrl?: string;
38
38
+
};
39
39
+
libraryCount: number;
40
40
+
urlLibraryCount: number;
41
41
+
urlInLibrary?: boolean;
42
42
+
createdAt: string;
43
43
+
updatedAt: string;
44
44
+
author: UserDTO;
45
45
+
note?: {
46
46
+
id: string;
47
47
+
text: string;
48
48
+
};
49
49
+
}
50
50
+
51
51
+
export interface LibraryForUrlDTO {
52
52
+
user: UserDTO;
53
53
+
card: UrlCardDTO;
54
54
+
}
55
55
+
19
56
export interface GetLibrariesForUrlResult {
20
57
libraries: LibraryForUrlDTO[];
21
58
pagination: {
···
41
78
export class GetLibrariesForUrlUseCase
42
79
implements UseCase<GetLibrariesForUrlQuery, Result<GetLibrariesForUrlResult>>
43
80
{
44
44
-
constructor(private cardQueryRepo: ICardQueryRepository) {}
81
81
+
constructor(
82
82
+
private cardQueryRepo: ICardQueryRepository,
83
83
+
private profileService: IProfileService,
84
84
+
) {}
45
85
46
86
async execute(
47
87
query: GetLibrariesForUrlQuery,
···
61
101
const sortOrder = query.sortOrder || SortOrder.DESC;
62
102
63
103
try {
64
64
-
// Execute query to get libraries for the URL
104
104
+
// Execute query to get libraries with full card data
65
105
const result = await this.cardQueryRepo.getLibrariesForUrl(query.url, {
66
106
page,
67
107
limit,
···
69
109
sortOrder,
70
110
});
71
111
112
112
+
// Enrich with user profiles
113
113
+
const uniqueUserIds = Array.from(
114
114
+
new Set(result.items.map((item) => item.userId)),
115
115
+
);
116
116
+
117
117
+
const profilePromises = uniqueUserIds.map((userId) =>
118
118
+
this.profileService.getProfile(userId, query.callingUserId),
119
119
+
);
120
120
+
121
121
+
const profileResults = await Promise.all(profilePromises);
122
122
+
123
123
+
// Create a map of profiles
124
124
+
const profileMap = new Map<string, UserDTO>();
125
125
+
126
126
+
for (let i = 0; i < uniqueUserIds.length; i++) {
127
127
+
const profileResult = profileResults[i];
128
128
+
const userId = uniqueUserIds[i];
129
129
+
if (!profileResult || !userId) {
130
130
+
return err(new Error('Missing profile result or user ID'));
131
131
+
}
132
132
+
if (profileResult.isErr()) {
133
133
+
return err(
134
134
+
new Error(
135
135
+
`Failed to fetch user profile: ${profileResult.error instanceof Error ? profileResult.error.message : 'Unknown error'}`,
136
136
+
),
137
137
+
);
138
138
+
}
139
139
+
const profile = profileResult.value;
140
140
+
profileMap.set(userId, {
141
141
+
id: profile.id,
142
142
+
name: profile.name,
143
143
+
handle: profile.handle,
144
144
+
avatarUrl: profile.avatarUrl,
145
145
+
description: profile.bio,
146
146
+
});
147
147
+
}
148
148
+
149
149
+
// Map items with enriched user data and card data
150
150
+
const enrichedLibraries: LibraryForUrlDTO[] = result.items.map((item) => {
151
151
+
const user = profileMap.get(item.userId);
152
152
+
if (!user) {
153
153
+
throw new Error(`Profile not found for user ${item.userId}`);
154
154
+
}
155
155
+
156
156
+
// Build card object
157
157
+
// Note: userId is the card author (it's their card in their library)
158
158
+
const card: UrlCardDTO = {
159
159
+
id: item.card.id,
160
160
+
type: 'URL',
161
161
+
url: item.card.url,
162
162
+
cardContent: item.card.cardContent,
163
163
+
libraryCount: item.card.libraryCount,
164
164
+
urlLibraryCount: item.card.urlLibraryCount,
165
165
+
urlInLibrary: item.card.urlInLibrary,
166
166
+
createdAt: item.card.createdAt.toISOString(),
167
167
+
updatedAt: item.card.updatedAt.toISOString(),
168
168
+
author: user, // Card author is same as library user
169
169
+
note: item.card.note,
170
170
+
};
171
171
+
172
172
+
return {
173
173
+
user,
174
174
+
card,
175
175
+
};
176
176
+
});
177
177
+
72
178
return ok({
73
73
-
libraries: result.items,
179
179
+
libraries: enrichedLibraries,
74
180
pagination: {
75
181
currentPage: page,
76
182
totalPages: Math.ceil(result.totalCount / limit),
+3
-1
src/modules/cards/application/useCases/queries/GetNoteCardsForUrlUseCase.ts
View file
Reviewed
···
25
25
name: string;
26
26
handle: string;
27
27
avatarUrl?: string;
28
28
+
description?: string;
28
29
};
29
30
createdAt: Date;
30
31
updatedAt: Date;
···
100
101
// Create a map of profiles
101
102
const profileMap = new Map<
102
103
string,
103
103
-
{ id: string; name: string; handle: string; avatarUrl?: string }
104
104
+
{ id: string; name: string; handle: string; avatarUrl?: string; description?: string }
104
105
>();
105
106
106
107
for (let i = 0; i < uniqueAuthorIds.length; i++) {
···
122
123
name: profile.name,
123
124
handle: profile.handle,
124
125
avatarUrl: profile.avatarUrl,
126
126
+
description: profile.bio,
125
127
});
126
128
}
127
129
+105
-7
src/modules/cards/application/useCases/queries/GetUrlCardViewUseCase.ts
View file
Reviewed
···
7
7
WithCollections,
8
8
} from '../../../domain/ICardQueryRepository';
9
9
import { IProfileService } from '../../../domain/services/IProfileService';
10
10
+
import { ICollectionRepository } from '../../../domain/ICollectionRepository';
11
11
+
import { CollectionId } from '../../../domain/value-objects/CollectionId';
10
12
11
13
export interface GetUrlCardViewQuery {
12
14
cardId: string;
···
14
16
}
15
17
16
18
// Enriched data for the final use case result
17
17
-
export type UrlCardViewResult = UrlCardView &
18
18
-
WithCollections & {
19
19
-
libraries: {
20
20
-
userId: string;
19
19
+
export type UrlCardViewResult = UrlCardView & {
20
20
+
author: {
21
21
+
id: string;
22
22
+
name: string;
23
23
+
handle: string;
24
24
+
avatarUrl?: string;
25
25
+
description?: string;
26
26
+
};
27
27
+
collections: {
28
28
+
id: string;
29
29
+
uri?: string;
30
30
+
name: string;
31
31
+
description?: string;
32
32
+
author: {
33
33
+
id: string;
21
34
name: string;
22
35
handle: string;
23
36
avatarUrl?: string;
24
24
-
}[];
25
25
-
};
37
37
+
description?: string;
38
38
+
};
39
39
+
cardCount: number;
40
40
+
createdAt: string;
41
41
+
updatedAt: string;
42
42
+
}[];
43
43
+
libraries: {
44
44
+
id: string;
45
45
+
name: string;
46
46
+
handle: string;
47
47
+
avatarUrl?: string;
48
48
+
description?: string;
49
49
+
}[];
50
50
+
};
26
51
27
52
export class ValidationError extends Error {
28
53
constructor(message: string) {
···
44
69
constructor(
45
70
private cardQueryRepo: ICardQueryRepository,
46
71
private profileService: IProfileService,
72
72
+
private collectionRepo: ICollectionRepository,
47
73
) {}
48
74
49
75
async execute(
···
66
92
return err(new CardNotFoundError('URL card not found'));
67
93
}
68
94
95
95
+
// Fetch card author profile
96
96
+
const cardAuthorResult = await this.profileService.getProfile(
97
97
+
cardView.authorId,
98
98
+
query.callingUserId,
99
99
+
);
100
100
+
101
101
+
if (cardAuthorResult.isErr()) {
102
102
+
return err(
103
103
+
new Error(
104
104
+
`Failed to fetch card author: ${cardAuthorResult.error.message}`,
105
105
+
),
106
106
+
);
107
107
+
}
108
108
+
109
109
+
const cardAuthor = cardAuthorResult.value;
110
110
+
69
111
// Get profiles for all users in libraries
70
112
const userIds = cardView.libraries.map((lib) => lib.userId);
71
113
const profilePromises = userIds.map((userId) =>
···
96
138
const profile = profileResult.value;
97
139
98
140
return {
99
99
-
userId: lib.userId,
141
141
+
id: profile.id,
100
142
name: profile.name,
101
143
handle: profile.handle,
102
144
avatarUrl: profile.avatarUrl,
145
145
+
description: profile.bio,
103
146
};
104
147
});
105
148
149
149
+
// Enrich collections with full Collection data
150
150
+
const enrichedCollections = await Promise.all(
151
151
+
cardView.collections.map(async (collection) => {
152
152
+
const collectionIdResult =
153
153
+
CollectionId.createFromString(collection.id);
154
154
+
if (collectionIdResult.isErr()) {
155
155
+
throw new Error(`Invalid collection ID: ${collection.id}`);
156
156
+
}
157
157
+
const collectionResult = await this.collectionRepo.findById(
158
158
+
collectionIdResult.value,
159
159
+
);
160
160
+
if (collectionResult.isErr()) {
161
161
+
throw new Error(`Collection not found: ${collection.id}`);
162
162
+
}
163
163
+
const fullCollection = collectionResult.value;
164
164
+
165
165
+
// Fetch collection author profile
166
166
+
const collectionAuthorResult = await this.profileService.getProfile(
167
167
+
fullCollection.curatorId.value,
168
168
+
query.callingUserId,
169
169
+
);
170
170
+
if (collectionAuthorResult.isErr()) {
171
171
+
throw new Error(
172
172
+
`Failed to fetch collection author: ${collectionAuthorResult.error.message}`,
173
173
+
);
174
174
+
}
175
175
+
const collectionAuthor = collectionAuthorResult.value;
176
176
+
177
177
+
return {
178
178
+
id: collection.id,
179
179
+
uri: fullCollection.uri,
180
180
+
name: collection.name,
181
181
+
description: fullCollection.description?.value,
182
182
+
author: {
183
183
+
id: collectionAuthor.id,
184
184
+
name: collectionAuthor.name,
185
185
+
handle: collectionAuthor.handle,
186
186
+
avatarUrl: collectionAuthor.avatarUrl,
187
187
+
description: collectionAuthor.bio,
188
188
+
},
189
189
+
cardCount: fullCollection.cardCount,
190
190
+
createdAt: fullCollection.createdAt.toISOString(),
191
191
+
updatedAt: fullCollection.updatedAt.toISOString(),
192
192
+
};
193
193
+
}),
194
194
+
);
195
195
+
106
196
const result: UrlCardViewResult = {
107
197
...cardView,
198
198
+
author: {
199
199
+
id: cardAuthor.id,
200
200
+
name: cardAuthor.name,
201
201
+
handle: cardAuthor.handle,
202
202
+
avatarUrl: cardAuthor.avatarUrl,
203
203
+
description: cardAuthor.bio,
204
204
+
},
205
205
+
collections: enrichedCollections,
108
206
libraries: enrichedLibraries,
109
207
};
110
208
+66
-6
src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts
View file
Reviewed
···
5
5
import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher';
6
6
import { ICardRepository } from '../../../domain/ICardRepository';
7
7
import { ICollectionQueryRepository } from '../../../domain/ICollectionQueryRepository';
8
8
+
import { ICollectionRepository } from '../../../domain/ICollectionRepository';
9
9
+
import { IProfileService } from '../../../domain/services/IProfileService';
8
10
import { CuratorId } from '../../../domain/value-objects/CuratorId';
9
11
import { URL } from '../../../domain/value-objects/URL';
12
12
+
import { CollectionId } from '../../../domain/value-objects/CollectionId';
10
13
11
14
export interface GetUrlStatusForMyLibraryQuery {
12
15
url: string;
···
18
21
uri?: string;
19
22
name: string;
20
23
description?: string;
24
24
+
author: {
25
25
+
id: string;
26
26
+
name: string;
27
27
+
handle: string;
28
28
+
avatarUrl?: string;
29
29
+
description?: string;
30
30
+
};
31
31
+
cardCount: number;
32
32
+
createdAt: string;
33
33
+
updatedAt: string;
21
34
}
22
35
23
36
export interface GetUrlStatusForMyLibraryResult {
···
41
54
constructor(
42
55
private cardRepository: ICardRepository,
43
56
private collectionQueryRepository: ICollectionQueryRepository,
57
57
+
private collectionRepo: ICollectionRepository,
58
58
+
private profileService: IProfileService,
44
59
eventPublisher: IEventPublisher,
45
60
) {
46
61
super(eventPublisher);
···
96
111
curatorId.value,
97
112
);
98
113
99
99
-
result.collections = collections.map((collection) => ({
100
100
-
id: collection.id,
101
101
-
uri: collection.uri,
102
102
-
name: collection.name,
103
103
-
description: collection.description,
104
104
-
}));
114
114
+
// Enrich collections with full data
115
115
+
result.collections = await Promise.all(
116
116
+
collections.map(async (collection) => {
117
117
+
// Fetch full collection to get dates and cardCount
118
118
+
const collectionIdResult = CollectionId.createFromString(
119
119
+
collection.id,
120
120
+
);
121
121
+
if (collectionIdResult.isErr()) {
122
122
+
throw new Error(
123
123
+
`Invalid collection ID: ${collection.id}`,
124
124
+
);
125
125
+
}
126
126
+
const collectionResult = await this.collectionRepo.findById(
127
127
+
collectionIdResult.value,
128
128
+
);
129
129
+
if (collectionResult.isErr()) {
130
130
+
throw new Error(
131
131
+
`Collection not found: ${collection.id}`,
132
132
+
);
133
133
+
}
134
134
+
const fullCollection = collectionResult.value;
135
135
+
136
136
+
// Fetch author profile
137
137
+
const authorProfileResult = await this.profileService.getProfile(
138
138
+
fullCollection.curatorId.value,
139
139
+
);
140
140
+
if (authorProfileResult.isErr()) {
141
141
+
throw new Error(
142
142
+
`Failed to fetch author profile: ${authorProfileResult.error.message}`,
143
143
+
);
144
144
+
}
145
145
+
const authorProfile = authorProfileResult.value;
146
146
+
147
147
+
return {
148
148
+
id: collection.id,
149
149
+
uri: collection.uri,
150
150
+
name: collection.name,
151
151
+
description: collection.description,
152
152
+
author: {
153
153
+
id: authorProfile.id,
154
154
+
name: authorProfile.name,
155
155
+
handle: authorProfile.handle,
156
156
+
avatarUrl: authorProfile.avatarUrl,
157
157
+
description: authorProfile.bio,
158
158
+
},
159
159
+
cardCount: fullCollection.cardCount,
160
160
+
createdAt: fullCollection.createdAt.toISOString(),
161
161
+
updatedAt: fullCollection.updatedAt.toISOString(),
162
162
+
};
163
163
+
}),
164
164
+
);
105
165
} catch (error) {
106
166
return err(AppError.UnexpectedError.create(error));
107
167
}
+23
-1
src/modules/cards/domain/ICardQueryRepository.ts
View file
Reviewed
···
41
41
urlInLibrary?: boolean;
42
42
createdAt: Date;
43
43
updatedAt: Date;
44
44
+
authorId: string; // NEW - needed to enrich with author profile
44
45
note?: {
45
46
id: string;
46
47
text: string;
···
61
62
// DTO for single URL card view with library and collection info
62
63
export type UrlCardViewDTO = UrlCardView & WithCollections & WithLibraries;
63
64
65
65
+
// Repository returns card data - will be enriched with user profile in use case
64
66
export interface LibraryForUrlDTO {
65
67
userId: string;
66
66
-
cardId: string;
68
68
+
card: {
69
69
+
id: string;
70
70
+
url: string;
71
71
+
cardContent: {
72
72
+
url: string;
73
73
+
title?: string;
74
74
+
description?: string;
75
75
+
author?: string;
76
76
+
thumbnailUrl?: string;
77
77
+
};
78
78
+
libraryCount: number;
79
79
+
urlLibraryCount: number;
80
80
+
urlInLibrary?: boolean;
81
81
+
createdAt: Date;
82
82
+
updatedAt: Date;
83
83
+
note?: {
84
84
+
id: string;
85
85
+
text: string;
86
86
+
};
87
87
+
};
88
88
+
// Note: userId is the card author in this context (it's their card in their library)
67
89
}
68
90
69
91
// Raw repository DTO - what the repository returns (not enriched)
+6
src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts
View file
Reviewed
···
73
73
// Raw data for URL card queries
74
74
export interface RawUrlCardData {
75
75
id: string;
76
76
+
authorId: string;
76
77
url: string;
77
78
contentData: any;
78
79
libraryCount: number;
···
375
376
urlInLibrary: raw.urlInLibrary,
376
377
createdAt: raw.createdAt,
377
378
updatedAt: raw.updatedAt,
379
379
+
authorId: raw.authorId,
378
380
collections: raw.collections,
379
381
note,
380
382
};
···
382
384
383
385
public static toCollectionCardQueryResult(raw: {
384
386
id: string;
387
387
+
authorId: string;
385
388
url: string;
386
389
contentData: any;
387
390
libraryCount: number;
···
421
424
urlInLibrary: raw.urlInLibrary,
422
425
createdAt: raw.createdAt,
423
426
updatedAt: raw.updatedAt,
427
427
+
authorId: raw.authorId,
424
428
note,
425
429
};
426
430
}
···
428
432
public static toUrlCardViewDTO(raw: {
429
433
id: string;
430
434
type: string;
435
435
+
authorId: string;
431
436
url: string;
432
437
contentData: UrlContentData;
433
438
libraryCount: number;
···
475
480
urlInLibrary: raw.urlInLibrary,
476
481
createdAt: raw.createdAt,
477
482
updatedAt: raw.updatedAt,
483
483
+
authorId: raw.authorId,
478
484
collections: raw.inCollections,
479
485
libraries: raw.inLibraries,
480
486
note,
+2
src/modules/cards/infrastructure/repositories/query-services/CollectionCardQueryService.ts
View file
Reviewed
···
52
52
const cardsQuery = this.db
53
53
.select({
54
54
id: cards.id,
55
55
+
authorId: cards.authorId,
55
56
url: cards.url,
56
57
contentData: cards.contentData,
57
58
libraryCount: cards.libraryCount,
···
179
180
180
181
return {
181
182
id: card.id,
183
183
+
authorId: card.authorId,
182
184
url: card.url || '',
183
185
contentData: card.contentData,
184
186
libraryCount: card.libraryCount,
+80
-6
src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts
View file
Reviewed
···
34
34
const urlCardsQuery = this.db
35
35
.select({
36
36
id: cards.id,
37
37
+
authorId: cards.authorId,
37
38
url: cards.url,
38
39
contentData: cards.contentData,
39
40
libraryCount: cards.libraryCount,
···
179
180
180
181
return {
181
182
id: card.id,
183
183
+
authorId: card.authorId,
182
184
url: card.url || '',
183
185
contentData: card.contentData,
184
186
libraryCount: card.libraryCount,
···
222
224
.select({
223
225
id: cards.id,
224
226
type: cards.type,
227
227
+
authorId: cards.authorId,
225
228
url: cards.url,
226
229
contentData: cards.contentData,
227
230
libraryCount: cards.libraryCount,
···
321
324
const urlCardView = CardMapper.toUrlCardViewDTO({
322
325
id: card.id,
323
326
type: card.type,
327
327
+
authorId: card.authorId,
324
328
url: card.url || '',
325
329
contentData: card.contentData,
326
330
libraryCount: card.libraryCount,
···
361
365
const librariesQuery = this.db
362
366
.select({
363
367
userId: libraryMemberships.userId,
364
364
-
cardId: libraryMemberships.cardId,
368
368
+
cardId: cards.id,
369
369
+
url: cards.url,
370
370
+
contentData: cards.contentData,
371
371
+
libraryCount: cards.libraryCount,
372
372
+
createdAt: cards.createdAt,
373
373
+
updatedAt: cards.updatedAt,
365
374
})
366
375
.from(libraryMemberships)
367
376
.innerJoin(cards, eq(libraryMemberships.cardId, cards.id))
···
371
380
372
381
const librariesResult = await librariesQuery;
373
382
374
374
-
// Get total count
383
383
+
// Get total count (needed even if current page is empty)
375
384
const totalCountResult = await this.db
376
385
.select({ count: count() })
377
386
.from(libraryMemberships)
···
379
388
.where(and(eq(cards.url, url), eq(cards.type, CardTypeEnum.URL)));
380
389
381
390
const totalCount = totalCountResult[0]?.count || 0;
391
391
+
392
392
+
if (librariesResult.length === 0) {
393
393
+
return {
394
394
+
items: [],
395
395
+
totalCount,
396
396
+
hasMore: false,
397
397
+
};
398
398
+
}
399
399
+
400
400
+
const cardIds = librariesResult.map((lib) => lib.cardId);
401
401
+
402
402
+
// Get notes for these cards
403
403
+
const notesQuery = this.db
404
404
+
.select({
405
405
+
id: cards.id,
406
406
+
parentCardId: cards.parentCardId,
407
407
+
contentData: cards.contentData,
408
408
+
})
409
409
+
.from(cards)
410
410
+
.where(
411
411
+
and(
412
412
+
eq(cards.type, CardTypeEnum.NOTE),
413
413
+
inArray(cards.parentCardId, cardIds),
414
414
+
),
415
415
+
);
416
416
+
417
417
+
const notesResult = await notesQuery;
418
418
+
419
419
+
// Get urlLibraryCount for this URL
420
420
+
const urlLibraryCountQuery = this.db
421
421
+
.select({
422
422
+
count: countDistinct(libraryMemberships.userId),
423
423
+
})
424
424
+
.from(cards)
425
425
+
.innerJoin(libraryMemberships, eq(cards.id, libraryMemberships.cardId))
426
426
+
.where(and(eq(cards.type, CardTypeEnum.URL), eq(cards.url, url)));
427
427
+
428
428
+
const urlLibraryCountResult = await urlLibraryCountQuery;
429
429
+
const urlLibraryCount = urlLibraryCountResult[0]?.count || 0;
430
430
+
382
431
const hasMore = offset + librariesResult.length < totalCount;
383
432
384
384
-
const items = librariesResult.map((lib) => ({
385
385
-
userId: lib.userId,
386
386
-
cardId: lib.cardId,
387
387
-
}));
433
433
+
const items: LibraryForUrlDTO[] = librariesResult.map((lib) => {
434
434
+
const note = notesResult.find((n) => n.parentCardId === lib.cardId);
435
435
+
436
436
+
return {
437
437
+
userId: lib.userId,
438
438
+
card: {
439
439
+
id: lib.cardId,
440
440
+
url: lib.url || '',
441
441
+
cardContent: {
442
442
+
url: lib.contentData?.url,
443
443
+
title: lib.contentData?.metadata?.title,
444
444
+
description: lib.contentData?.metadata?.description,
445
445
+
author: lib.contentData?.metadata?.author,
446
446
+
thumbnailUrl: lib.contentData?.metadata?.imageUrl,
447
447
+
},
448
448
+
libraryCount: lib.libraryCount,
449
449
+
urlLibraryCount,
450
450
+
urlInLibrary: true, // By definition, if it's in this result, it's in a library
451
451
+
createdAt: lib.createdAt,
452
452
+
updatedAt: lib.updatedAt,
453
453
+
note: note
454
454
+
? {
455
455
+
id: note.id,
456
456
+
text: note.contentData?.text || '',
457
457
+
}
458
458
+
: undefined,
459
459
+
},
460
460
+
};
461
461
+
});
388
462
389
463
return {
390
464
items,
+4
-4
src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getLibrariesForUrl.integration.test.ts
View file
Reviewed
···
118
118
expect(userIds).toContain(curator3.value);
119
119
120
120
// Check that card IDs are correct
121
121
-
const cardIds = result.items.map((lib) => lib.cardId);
121
121
+
const cardIds = result.items.map((lib) => lib.card.id);
122
122
expect(cardIds).toContain(card1.cardId.getStringValue());
123
123
expect(cardIds).toContain(card2.cardId.getStringValue());
124
124
expect(cardIds).toContain(card3.cardId.getStringValue());
···
174
174
175
175
expect(result.items).toHaveLength(1);
176
176
expect(result.items[0]!.userId).toBe(curator1.value);
177
177
-
expect(result.items[0]!.cardId).toBe(card1.cardId.getStringValue());
177
177
+
expect(result.items[0]!.card.id).toBe(card1.cardId.getStringValue());
178
178
});
179
179
180
180
it('should not return NOTE cards even if they have the same URL', async () => {
···
211
211
// Should only return the URL card, not the NOTE card
212
212
expect(result.items).toHaveLength(1);
213
213
expect(result.items[0]!.userId).toBe(curator1.value);
214
214
-
expect(result.items[0]!.cardId).toBe(urlCard.cardId.getStringValue());
214
214
+
expect(result.items[0]!.card.id).toBe(urlCard.cardId.getStringValue());
215
215
});
216
216
217
217
it('should handle multiple cards from same user with same URL', async () => {
···
253
253
expect(result.items[1]!.userId).toBe(curator1.value);
254
254
255
255
// But different card IDs
256
256
-
const cardIds = result.items.map((lib) => lib.cardId);
256
256
+
const cardIds = result.items.map((lib) => lib.card.id);
257
257
expect(cardIds).toContain(card1.cardId.getStringValue());
258
258
expect(cardIds).toContain(card2.cardId.getStringValue());
259
259
});
+50
-171
src/webapp/api-client/types/responses.ts
View file
Reviewed
···
58
58
metadata: UrlMetadata;
59
59
}
60
60
61
61
-
export interface UrlCardView {
61
61
+
// Unified UrlCard interface - base for all card responses
62
62
+
export interface UrlCard {
62
63
id: string;
63
64
type: 'URL';
64
65
url: string;
···
74
75
urlInLibrary?: boolean;
75
76
createdAt: string;
76
77
updatedAt: string;
78
78
+
author: User;
77
79
note?: {
78
80
id: string;
79
81
text: string;
80
82
};
81
81
-
collections: {
82
82
-
id: string;
83
83
-
name: string;
84
84
-
authorId: string;
85
85
-
}[];
86
86
-
libraries: {
87
87
-
userId: string;
88
88
-
name: string;
89
89
-
handle: string;
90
90
-
avatarUrl?: string;
91
91
-
}[];
92
83
}
93
84
94
94
-
export interface GetUrlCardViewResponse extends UrlCardView {}
85
85
+
// Unified Collection interface - used across all endpoints
86
86
+
export interface Collection {
87
87
+
id: string;
88
88
+
uri?: string;
89
89
+
name: string;
90
90
+
author: User;
91
91
+
description?: string;
92
92
+
cardCount: number;
93
93
+
createdAt: string;
94
94
+
updatedAt: string;
95
95
+
}
95
96
96
96
-
export interface LibraryUser {
97
97
+
// Context-specific variations
98
98
+
export interface UrlCardWithCollections extends UrlCard {
99
99
+
collections: Collection[];
100
100
+
}
101
101
+
102
102
+
export interface UrlCardWithLibraries extends UrlCard {
103
103
+
libraries: User[];
104
104
+
}
105
105
+
106
106
+
export interface UrlCardWithCollectionsAndLibraries extends UrlCard {
107
107
+
collections: Collection[];
108
108
+
libraries: User[];
109
109
+
}
110
110
+
111
111
+
export interface GetUrlCardViewResponse extends UrlCardWithCollectionsAndLibraries {}
112
112
+
113
113
+
// Unified User interface - used across all endpoints
114
114
+
export interface User {
97
115
id: string;
98
116
name: string;
99
117
handle: string;
100
118
avatarUrl?: string;
119
119
+
description?: string;
101
120
}
102
121
103
122
export interface GetLibrariesForCardResponse {
104
123
cardId: string;
105
105
-
users: LibraryUser[];
124
124
+
users: User[];
106
125
totalCount: number;
107
126
}
108
127
109
109
-
export interface UserProfile {
110
110
-
id: string;
111
111
-
name: string;
112
112
-
handle: string;
113
113
-
description?: string;
114
114
-
avatarUrl?: string;
115
115
-
}
128
128
+
export interface GetProfileResponse extends User {}
116
129
117
117
-
export interface GetProfileResponse extends UserProfile {}
118
118
-
119
119
-
export interface UrlCardListItem {
120
120
-
id: string;
121
121
-
type: 'URL';
122
122
-
url: string;
123
123
-
cardContent: {
124
124
-
url: string;
125
125
-
title?: string;
126
126
-
description?: string;
127
127
-
author?: string;
128
128
-
thumbnailUrl?: string;
129
129
-
};
130
130
-
libraryCount: number;
131
131
-
urlLibraryCount: number;
132
132
-
urlInLibrary?: boolean;
133
133
-
createdAt: string;
134
134
-
updatedAt: string;
135
135
-
note?: {
136
136
-
id: string;
137
137
-
text: string;
138
138
-
};
139
139
-
collections: {
140
140
-
id: string;
141
141
-
name: string;
142
142
-
authorId: string;
143
143
-
}[];
144
144
-
}
145
130
146
131
// Base pagination interface
147
132
export interface Pagination {
···
167
152
}
168
153
169
154
export interface GetUrlCardsResponse {
170
170
-
cards: UrlCardListItem[];
155
155
+
cards: UrlCardWithCollections[];
171
156
pagination: Pagination;
172
157
sorting: CardSorting;
173
158
}
174
159
175
175
-
export interface CollectionPageUrlCard {
176
176
-
id: string;
177
177
-
type: 'URL';
178
178
-
url: string;
179
179
-
cardContent: {
180
180
-
url: string;
181
181
-
title?: string;
182
182
-
description?: string;
183
183
-
author?: string;
184
184
-
thumbnailUrl?: string;
185
185
-
};
186
186
-
libraryCount: number;
187
187
-
urlLibraryCount: number;
188
188
-
urlInLibrary?: boolean;
189
189
-
createdAt: string;
190
190
-
updatedAt: string;
191
191
-
note?: {
192
192
-
id: string;
193
193
-
text: string;
194
194
-
};
195
195
-
}
196
196
-
197
160
export interface GetCollectionPageResponse {
198
161
id: string;
199
162
uri?: string;
200
163
name: string;
201
164
description?: string;
202
202
-
author: {
203
203
-
id: string;
204
204
-
name: string;
205
205
-
handle: string;
206
206
-
avatarUrl?: string;
207
207
-
};
208
208
-
urlCards: CollectionPageUrlCard[];
165
165
+
author: User;
166
166
+
urlCards: UrlCard[];
167
167
+
cardCount: number;
168
168
+
createdAt: string;
169
169
+
updatedAt: string;
209
170
pagination: Pagination;
210
171
sorting: CardSorting;
211
172
}
212
173
213
174
export interface GetCollectionsResponse {
214
214
-
collections: {
215
215
-
id: string;
216
216
-
uri?: string;
217
217
-
name: string;
218
218
-
description?: string;
219
219
-
cardCount: number;
220
220
-
createdAt: string;
221
221
-
updatedAt: string;
222
222
-
createdBy: {
223
223
-
id: string;
224
224
-
name: string;
225
225
-
handle: string;
226
226
-
avatarUrl?: string;
227
227
-
};
228
228
-
}[];
175
175
+
collections: Collection[];
229
176
pagination: Pagination;
230
177
sorting: CollectionSorting;
231
178
}
···
256
203
}
257
204
258
205
// Feed response types
259
259
-
export interface FeedActivityActor {
260
260
-
id: string;
261
261
-
name: string;
262
262
-
handle: string;
263
263
-
avatarUrl?: string;
264
264
-
}
265
265
-
266
266
-
export interface FeedActivityCard {
267
267
-
id: string;
268
268
-
type: 'URL';
269
269
-
url: string;
270
270
-
cardContent: {
271
271
-
url: string;
272
272
-
title?: string;
273
273
-
description?: string;
274
274
-
author?: string;
275
275
-
thumbnailUrl?: string;
276
276
-
};
277
277
-
libraryCount: number;
278
278
-
urlLibraryCount: number;
279
279
-
urlInLibrary?: boolean;
280
280
-
createdAt: string;
281
281
-
updatedAt: string;
282
282
-
note?: {
283
283
-
id: string;
284
284
-
text: string;
285
285
-
};
286
286
-
collections: {
287
287
-
id: string;
288
288
-
name: string;
289
289
-
authorId: string;
290
290
-
}[];
291
291
-
libraries: {
292
292
-
userId: string;
293
293
-
name: string;
294
294
-
handle: string;
295
295
-
avatarUrl?: string;
296
296
-
}[];
297
297
-
}
298
298
-
299
206
export interface FeedItem {
300
207
id: string;
301
301
-
user: FeedActivityActor;
302
302
-
card: FeedActivityCard;
208
208
+
user: User;
209
209
+
card: UrlCard;
303
210
createdAt: Date;
304
304
-
collections: {
305
305
-
id: string;
306
306
-
name: string;
307
307
-
authorHandle: string;
308
308
-
uri?: string;
309
309
-
}[];
211
211
+
collections: Collection[];
310
212
}
311
213
312
214
export interface FeedPagination extends Pagination {
···
320
222
321
223
export interface GetUrlStatusForMyLibraryResponse {
322
224
cardId?: string;
323
323
-
collections?: {
324
324
-
id: string;
325
325
-
uri?: string;
326
326
-
name: string;
327
327
-
description?: string;
328
328
-
}[];
225
225
+
collections?: Collection[];
329
226
}
330
227
331
228
export interface GetLibrariesForUrlResponse {
332
229
libraries: {
333
333
-
userId: string;
334
334
-
name: string;
335
335
-
handle: string;
336
336
-
avatarUrl?: string;
230
230
+
user: User;
231
231
+
card: UrlCard;
337
232
}[];
338
233
pagination: Pagination;
339
234
sorting: CardSorting;
···
343
238
notes: {
344
239
id: string;
345
240
note: string;
346
346
-
author: {
347
347
-
id: string;
348
348
-
name: string;
349
349
-
handle: string;
350
350
-
avatarUrl?: string;
351
351
-
};
241
241
+
author: User;
352
242
createdAt: string;
353
243
updatedAt: string;
354
244
}[];
···
357
247
}
358
248
359
249
export interface GetCollectionsForUrlResponse {
360
360
-
collections: {
361
361
-
id: string;
362
362
-
uri?: string;
363
363
-
name: string;
364
364
-
description?: string;
365
365
-
author: {
366
366
-
id: string;
367
367
-
name: string;
368
368
-
handle: string;
369
369
-
avatarUrl?: string;
370
370
-
};
371
371
-
}[];
250
250
+
collections: Collection[];
372
251
pagination: Pagination;
373
252
sorting: CollectionSorting;
374
253
}