@shipfox/api-auth 17.0.0 → 19.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +44 -0
- package/README.md +24 -9
- package/dist/core/administration.d.ts +15 -0
- package/dist/core/administration.d.ts.map +1 -1
- package/dist/core/administration.js +44 -2
- package/dist/core/administration.js.map +1 -1
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +5 -3
- package/dist/core/auth.js.map +1 -1
- package/dist/core/entities/agent-access.d.ts +75 -0
- package/dist/core/entities/agent-access.d.ts.map +1 -0
- package/dist/core/entities/agent-access.js +3 -0
- package/dist/core/entities/agent-access.js.map +1 -0
- package/dist/core/errors.d.ts +3 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +6 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/runner-session-token.d.ts.map +1 -1
- package/dist/core/runner-session-token.js +2 -1
- package/dist/core/runner-session-token.js.map +1 -1
- package/dist/db/admin-user-moderation.d.ts.map +1 -1
- package/dist/db/admin-user-moderation.js +30 -26
- package/dist/db/admin-user-moderation.js.map +1 -1
- package/dist/db/admin-user-summary.d.ts +22 -0
- package/dist/db/admin-user-summary.d.ts.map +1 -1
- package/dist/db/admin-user-summary.js +75 -1
- package/dist/db/admin-user-summary.js.map +1 -1
- package/dist/db/admin-users.d.ts +11 -0
- package/dist/db/admin-users.d.ts.map +1 -1
- package/dist/db/admin-users.js +4 -1
- package/dist/db/admin-users.js.map +1 -1
- package/dist/db/agent-access.d.ts +159 -0
- package/dist/db/agent-access.d.ts.map +1 -0
- package/dist/db/agent-access.js +267 -0
- package/dist/db/agent-access.js.map +1 -0
- package/dist/db/db.d.ts +2604 -238
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +7 -0
- package/dist/db/db.js.map +1 -1
- package/dist/db/schema/agent-access.d.ts +1204 -0
- package/dist/db/schema/agent-access.d.ts.map +1 -0
- package/dist/db/schema/agent-access.js +267 -0
- package/dist/db/schema/agent-access.js.map +1 -0
- package/dist/db/schema/users.d.ts.map +1 -1
- package/dist/db/schema/users.js +8 -3
- package/dist/db/schema/users.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/metrics/instance.d.ts +1 -1
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js.map +1 -1
- package/dist/presentation/routes/administration.d.ts.map +1 -1
- package/dist/presentation/routes/administration.js +123 -20
- package/dist/presentation/routes/administration.js.map +1 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.js +6 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -1
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/rate-limit.js +10 -0
- package/dist/presentation/routes/rate-limit.js.map +1 -1
- package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
- package/dist/presentation/routes/registration/signup.js +59 -50
- package/dist/presentation/routes/registration/signup.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0003_brainy_luckman.sql +6 -0
- package/drizzle/0004_yummy_runaways.sql +110 -0
- package/drizzle/meta/0003_snapshot.json +844 -0
- package/drizzle/meta/0004_snapshot.json +1751 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +7 -7
- package/src/core/admin-role.test.ts +70 -0
- package/src/core/administration.ts +78 -1
- package/src/core/auth.ts +3 -5
- package/src/core/entities/agent-access.ts +80 -0
- package/src/core/errors.ts +7 -0
- package/src/core/runner-session-token.test.ts +2 -0
- package/src/core/runner-session-token.ts +1 -0
- package/src/db/admin-user-moderation.ts +53 -45
- package/src/db/admin-user-summary.test.ts +228 -0
- package/src/db/admin-user-summary.ts +143 -1
- package/src/db/admin-users.ts +21 -1
- package/src/db/agent-access.test.ts +370 -0
- package/src/db/agent-access.ts +629 -0
- package/src/db/db.ts +14 -0
- package/src/db/schema/agent-access.ts +273 -0
- package/src/db/schema/users.ts +9 -3
- package/src/index.ts +6 -0
- package/src/metrics/instance.ts +1 -0
- package/src/presentation/routes/administration.test.ts +359 -2
- package/src/presentation/routes/administration.ts +142 -20
- package/src/presentation/routes/email-verification/verify-email-resend.ts +7 -1
- package/src/presentation/routes/rate-limit.ts +4 -0
- package/src/presentation/routes/registration/signup.ts +52 -41
- package/test/globalSetup.ts +1 -1
- package/test/routes.ts +3 -1
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import {eq} from 'drizzle-orm';
|
|
2
|
+
import {userFactory} from '#test/index.js';
|
|
3
|
+
import {createAdminGrant, revokeAdminGrant} from './admin-grants.js';
|
|
4
|
+
import {listAdministratorUserSummaries} from './admin-user-summary.js';
|
|
5
|
+
import {db} from './db.js';
|
|
6
|
+
import {users} from './schema/users.js';
|
|
7
|
+
|
|
8
|
+
async function setCreatedAt(userId: string, createdAt: Date): Promise<void> {
|
|
9
|
+
await db().update(users).set({createdAt}).where(eq(users.id, userId));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('administrator user summaries db', () => {
|
|
13
|
+
test('lists users in descending created-at and id order with a limit-plus-one cursor', async () => {
|
|
14
|
+
const marker = `directory-order-${crypto.randomUUID()}`;
|
|
15
|
+
const actor = await userFactory.create({email: `${marker}-actor@example.com`});
|
|
16
|
+
const first = await userFactory.create({email: `${marker}-first@example.com`});
|
|
17
|
+
const second = await userFactory.create({email: `${marker}-second@example.com`});
|
|
18
|
+
const third = await userFactory.create({email: `${marker}-third@example.com`});
|
|
19
|
+
const base = new Date('2099-01-01T00:00:00.000Z');
|
|
20
|
+
await setCreatedAt(actor.id, new Date(base.getTime() - 1_000));
|
|
21
|
+
await setCreatedAt(first.id, new Date(base.getTime() + 2_000));
|
|
22
|
+
await setCreatedAt(second.id, new Date(base.getTime() + 2_000));
|
|
23
|
+
await setCreatedAt(third.id, new Date(base.getTime() + 1_000));
|
|
24
|
+
const sameTimestampOrder = [first, second].sort((left, right) => {
|
|
25
|
+
if (right.id < left.id) return -1;
|
|
26
|
+
if (right.id > left.id) return 1;
|
|
27
|
+
return 0;
|
|
28
|
+
});
|
|
29
|
+
const newerSameTimestamp = sameTimestampOrder[0];
|
|
30
|
+
const olderSameTimestamp = sameTimestampOrder[1];
|
|
31
|
+
if (!newerSameTimestamp || !olderSameTimestamp) {
|
|
32
|
+
throw new Error('Expected two users with the same timestamp');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const unfiltered = await listAdministratorUserSummaries(db(), {
|
|
36
|
+
actorId: actor.id,
|
|
37
|
+
limit: 4,
|
|
38
|
+
});
|
|
39
|
+
expect(unfiltered.rows.slice(0, 4).map(({id}) => id)).toEqual([
|
|
40
|
+
newerSameTimestamp.id,
|
|
41
|
+
olderSameTimestamp.id,
|
|
42
|
+
third.id,
|
|
43
|
+
actor.id,
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const firstPage = await listAdministratorUserSummaries(db(), {
|
|
47
|
+
actorId: actor.id,
|
|
48
|
+
limit: 2,
|
|
49
|
+
search: marker,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(firstPage.rows.map(({id}) => id)).toEqual([
|
|
53
|
+
newerSameTimestamp.id,
|
|
54
|
+
olderSameTimestamp.id,
|
|
55
|
+
]);
|
|
56
|
+
expect(firstPage.nextCursor).toEqual({
|
|
57
|
+
createdAt: new Date(base.getTime() + 2_000),
|
|
58
|
+
id: olderSameTimestamp.id,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const insertedAfterPage = await userFactory.create({
|
|
62
|
+
email: `${marker}-inserted@example.com`,
|
|
63
|
+
});
|
|
64
|
+
await setCreatedAt(insertedAfterPage.id, new Date(base.getTime() + 4_000));
|
|
65
|
+
|
|
66
|
+
const secondPage = await listAdministratorUserSummaries(db(), {
|
|
67
|
+
actorId: actor.id,
|
|
68
|
+
limit: 2,
|
|
69
|
+
search: marker,
|
|
70
|
+
...(firstPage.nextCursor ? {cursor: firstPage.nextCursor} : {}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(secondPage.rows.map(({id}) => id)).toEqual([third.id, actor.id]);
|
|
74
|
+
expect(secondPage.nextCursor).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('uses exact UUID search and case-insensitive multi-term literal matching', async () => {
|
|
78
|
+
const actor = await userFactory.create({email: `actor-${crypto.randomUUID()}@example.com`});
|
|
79
|
+
const user = await userFactory.create({
|
|
80
|
+
email: `literal%_\\${crypto.randomUUID()}@Example.com`,
|
|
81
|
+
name: 'Ada Lovelace',
|
|
82
|
+
});
|
|
83
|
+
await userFactory.create({
|
|
84
|
+
email: `control-${crypto.randomUUID()}@example.com`,
|
|
85
|
+
name: 'Ada Byron',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const exact = await listAdministratorUserSummaries(db(), {
|
|
89
|
+
actorId: actor.id,
|
|
90
|
+
search: user.id,
|
|
91
|
+
limit: 10,
|
|
92
|
+
});
|
|
93
|
+
expect(exact.rows.map(({id}) => id)).toEqual([user.id]);
|
|
94
|
+
|
|
95
|
+
const matching = await listAdministratorUserSummaries(db(), {
|
|
96
|
+
actorId: actor.id,
|
|
97
|
+
search: ' ADA LOVELACE ',
|
|
98
|
+
limit: 10,
|
|
99
|
+
});
|
|
100
|
+
expect(matching.rows.map(({id}) => id)).toEqual([user.id]);
|
|
101
|
+
|
|
102
|
+
const literal = await listAdministratorUserSummaries(db(), {
|
|
103
|
+
actorId: actor.id,
|
|
104
|
+
search: '%_',
|
|
105
|
+
limit: 10,
|
|
106
|
+
});
|
|
107
|
+
expect(literal.rows.map(({id}) => id)).toEqual([user.id]);
|
|
108
|
+
|
|
109
|
+
const literalBackslash = await listAdministratorUserSummaries(db(), {
|
|
110
|
+
actorId: actor.id,
|
|
111
|
+
search: '\\',
|
|
112
|
+
limit: 10,
|
|
113
|
+
});
|
|
114
|
+
expect(literalBackslash.rows.map(({id}) => id)).toEqual([user.id]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('ignores revoked grants when filtering eligibility and projecting roles', async () => {
|
|
118
|
+
const marker = `directory-revoked-${crypto.randomUUID()}`;
|
|
119
|
+
const actor = await userFactory.create({email: `${marker}-actor@example.com`});
|
|
120
|
+
const revoked = await userFactory.create({
|
|
121
|
+
email: `${marker}-revoked@example.com`,
|
|
122
|
+
emailVerifiedAt: new Date(),
|
|
123
|
+
});
|
|
124
|
+
const grant = await createAdminGrant({userId: revoked.id, role: 'admin-observer'});
|
|
125
|
+
await revokeAdminGrant({grantId: grant.id});
|
|
126
|
+
|
|
127
|
+
const eligibleRows = await listAdministratorUserSummaries(db(), {
|
|
128
|
+
actorId: actor.id,
|
|
129
|
+
search: marker,
|
|
130
|
+
eligible: true,
|
|
131
|
+
limit: 10,
|
|
132
|
+
});
|
|
133
|
+
const allRows = await listAdministratorUserSummaries(db(), {
|
|
134
|
+
actorId: actor.id,
|
|
135
|
+
search: marker,
|
|
136
|
+
limit: 10,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(eligibleRows.rows.map(({id}) => id)).toEqual([revoked.id]);
|
|
140
|
+
expect(allRows.rows).toHaveLength(2);
|
|
141
|
+
expect(allRows.rows).toEqual(
|
|
142
|
+
expect.arrayContaining([
|
|
143
|
+
expect.objectContaining({id: revoked.id, adminRole: null}),
|
|
144
|
+
expect.objectContaining({id: actor.id, adminRole: null}),
|
|
145
|
+
]),
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test.each(['active', 'suspended', 'deleted'] as const)('filters by %s status', async (status) => {
|
|
150
|
+
const marker = `directory-status-${status}-${crypto.randomUUID()}`;
|
|
151
|
+
const actor = await userFactory.create({email: `actor-${crypto.randomUUID()}@example.com`});
|
|
152
|
+
const user = await userFactory.create({email: `${marker}-user@example.com`});
|
|
153
|
+
await db().update(users).set({status}).where(eq(users.id, user.id));
|
|
154
|
+
|
|
155
|
+
const result = await listAdministratorUserSummaries(db(), {
|
|
156
|
+
actorId: actor.id,
|
|
157
|
+
search: marker,
|
|
158
|
+
status,
|
|
159
|
+
limit: 10,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(result.rows).toEqual([expect.objectContaining({id: user.id, status})]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('applies eligibility rules and projects the highest effective role once per user', async () => {
|
|
166
|
+
const marker = `directory-eligible-${crypto.randomUUID()}`;
|
|
167
|
+
const actor = await userFactory.create({
|
|
168
|
+
email: `${marker}-actor@example.com`,
|
|
169
|
+
emailVerifiedAt: new Date(),
|
|
170
|
+
});
|
|
171
|
+
const eligible = await userFactory.create({
|
|
172
|
+
email: `${marker}-eligible@example.com`,
|
|
173
|
+
emailVerifiedAt: new Date(),
|
|
174
|
+
});
|
|
175
|
+
const unverified = await userFactory.create({email: `${marker}-unverified@example.com`});
|
|
176
|
+
const granted = await userFactory.create({
|
|
177
|
+
email: `${marker}-granted@example.com`,
|
|
178
|
+
emailVerifiedAt: new Date(),
|
|
179
|
+
});
|
|
180
|
+
const suspended = await userFactory.create({
|
|
181
|
+
email: `${marker}-suspended@example.com`,
|
|
182
|
+
emailVerifiedAt: new Date(),
|
|
183
|
+
});
|
|
184
|
+
const deleted = await userFactory.create({
|
|
185
|
+
email: `${marker}-deleted@example.com`,
|
|
186
|
+
emailVerifiedAt: new Date(),
|
|
187
|
+
});
|
|
188
|
+
await db().update(users).set({status: 'suspended'}).where(eq(users.id, suspended.id));
|
|
189
|
+
await db().update(users).set({status: 'deleted'}).where(eq(users.id, deleted.id));
|
|
190
|
+
await createAdminGrant({userId: granted.id, role: 'admin-observer'});
|
|
191
|
+
await createAdminGrant({userId: granted.id, role: 'admin-operator'});
|
|
192
|
+
await createAdminGrant({userId: suspended.id, role: 'admin-observer'});
|
|
193
|
+
await createAdminGrant({userId: deleted.id, role: 'admin-owner'});
|
|
194
|
+
|
|
195
|
+
const all = await listAdministratorUserSummaries(db(), {
|
|
196
|
+
actorId: actor.id,
|
|
197
|
+
search: marker,
|
|
198
|
+
limit: 20,
|
|
199
|
+
});
|
|
200
|
+
expect(all.rows.filter(({id}) => id === granted.id)).toHaveLength(1);
|
|
201
|
+
expect(all.rows.find(({id}) => id === granted.id)?.adminRole).toBe('admin-operator');
|
|
202
|
+
expect(all.rows.find(({id}) => id === suspended.id)?.adminRole).toBeNull();
|
|
203
|
+
expect(all.rows.find(({id}) => id === deleted.id)?.adminRole).toBeNull();
|
|
204
|
+
|
|
205
|
+
const eligibleRows = await listAdministratorUserSummaries(db(), {
|
|
206
|
+
actorId: actor.id,
|
|
207
|
+
search: marker,
|
|
208
|
+
eligible: true,
|
|
209
|
+
limit: 20,
|
|
210
|
+
});
|
|
211
|
+
expect(eligibleRows.rows.map(({id}) => id)).toEqual([eligible.id]);
|
|
212
|
+
expect(eligibleRows.rows).not.toEqual(
|
|
213
|
+
expect.arrayContaining([expect.objectContaining({id: actor.id})]),
|
|
214
|
+
);
|
|
215
|
+
expect(unverified.id).not.toBe(eligibleRows.rows[0]?.id);
|
|
216
|
+
|
|
217
|
+
const ineligibleRows = await listAdministratorUserSummaries(db(), {
|
|
218
|
+
actorId: actor.id,
|
|
219
|
+
search: marker,
|
|
220
|
+
eligible: false,
|
|
221
|
+
limit: 20,
|
|
222
|
+
});
|
|
223
|
+
expect(ineligibleRows.rows.map(({id}) => id)).toEqual(
|
|
224
|
+
expect.arrayContaining([actor.id, unverified.id, granted.id, suspended.id, deleted.id]),
|
|
225
|
+
);
|
|
226
|
+
expect(ineligibleRows.rows.map(({id}) => id)).not.toContain(eligible.id);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -1,6 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type {AdminRole} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {
|
|
3
|
+
paginateTimestampIdRows,
|
|
4
|
+
type TimestampIdCursor,
|
|
5
|
+
timestampIdCursorWhere,
|
|
6
|
+
} from '@shipfox/node-drizzle';
|
|
7
|
+
import {
|
|
8
|
+
and,
|
|
9
|
+
desc,
|
|
10
|
+
eq,
|
|
11
|
+
isNotNull,
|
|
12
|
+
isNull,
|
|
13
|
+
ne,
|
|
14
|
+
not,
|
|
15
|
+
or,
|
|
16
|
+
type SQL,
|
|
17
|
+
type SQLWrapper,
|
|
18
|
+
sql,
|
|
19
|
+
} from 'drizzle-orm';
|
|
2
20
|
import {highestAdminRole} from '#core/admin-role-model.js';
|
|
3
21
|
import type {AdministratorUserSummary} from '#core/entities/administrator-read-model.js';
|
|
22
|
+
import type {UserStatus} from '#core/entities/user.js';
|
|
4
23
|
import type {db} from './db.js';
|
|
5
24
|
import {adminGrants} from './schema/admin-grants.js';
|
|
6
25
|
import {users} from './schema/users.js';
|
|
@@ -10,6 +29,64 @@ export type AdministratorUserSummaryExecutor = ReturnType<typeof db> | Tx;
|
|
|
10
29
|
|
|
11
30
|
type AdministratorUserLookup = {id: string; email?: never} | {email: string; id?: never};
|
|
12
31
|
|
|
32
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
33
|
+
const SEARCH_TERM_SEPARATOR = /\s+/;
|
|
34
|
+
|
|
35
|
+
export interface ListAdministratorUserSummariesParams {
|
|
36
|
+
actorId: string;
|
|
37
|
+
limit: number;
|
|
38
|
+
cursor?: TimestampIdCursor | undefined;
|
|
39
|
+
search?: string | undefined;
|
|
40
|
+
status?: UserStatus | undefined;
|
|
41
|
+
eligible?: boolean | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ListAdministratorUserSummariesResult {
|
|
45
|
+
rows: AdministratorUserSummary[];
|
|
46
|
+
nextCursor: TimestampIdCursor | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function escapeLikeTerm(term: string): string {
|
|
50
|
+
return term.replace(/[\\%_]/g, (character) => `\\${character}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isUuid(value: string): boolean {
|
|
54
|
+
return UUID_PATTERN.test(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function containsTerm(column: SQLWrapper, term: string): SQLWrapper {
|
|
58
|
+
return sql`${column} ILIKE ${`%${escapeLikeTerm(term)}%`}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function addEligibilityCondition(
|
|
62
|
+
conditions: SQL[],
|
|
63
|
+
params: Pick<ListAdministratorUserSummariesParams, 'actorId' | 'eligible'>,
|
|
64
|
+
activeAdminRoleUserId: SQLWrapper,
|
|
65
|
+
): void {
|
|
66
|
+
const eligibleCondition = and(
|
|
67
|
+
eq(users.status, 'active'),
|
|
68
|
+
isNotNull(users.emailVerifiedAt),
|
|
69
|
+
isNull(activeAdminRoleUserId),
|
|
70
|
+
ne(users.id, params.actorId),
|
|
71
|
+
);
|
|
72
|
+
if (eligibleCondition && params.eligible === true) conditions.push(eligibleCondition);
|
|
73
|
+
if (eligibleCondition && params.eligible === false) conditions.push(not(eligibleCondition));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function addSearchConditions(conditions: SQL[], search: string | undefined): void {
|
|
77
|
+
const normalizedSearch = search?.trim() ?? '';
|
|
78
|
+
if (!normalizedSearch) return;
|
|
79
|
+
if (isUuid(normalizedSearch)) {
|
|
80
|
+
conditions.push(eq(users.id, normalizedSearch));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const term of normalizedSearch.split(SEARCH_TERM_SEPARATOR)) {
|
|
85
|
+
const termCondition = or(containsTerm(users.name, term), containsTerm(users.email, term));
|
|
86
|
+
if (termCondition) conditions.push(termCondition);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
13
90
|
export async function findAdministratorUserSummary(
|
|
14
91
|
executor: AdministratorUserSummaryExecutor,
|
|
15
92
|
params: AdministratorUserLookup,
|
|
@@ -49,3 +126,68 @@ export async function findAdministratorUserSummary(
|
|
|
49
126
|
adminRole: highestAdminRole(rows.flatMap(({adminRole}) => (adminRole ? [adminRole] : []))),
|
|
50
127
|
} satisfies AdministratorUserSummary;
|
|
51
128
|
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Lists a live directory view using keyset pagination.
|
|
132
|
+
*
|
|
133
|
+
* Pages are not a consistent snapshot. Each page reads current account and grant state, so
|
|
134
|
+
* inserts and account or grant changes can make consecutive pages differ. Re-run the listing
|
|
135
|
+
* before acting on a row.
|
|
136
|
+
*/
|
|
137
|
+
export async function listAdministratorUserSummaries(
|
|
138
|
+
executor: AdministratorUserSummaryExecutor,
|
|
139
|
+
params: ListAdministratorUserSummariesParams,
|
|
140
|
+
): Promise<ListAdministratorUserSummariesResult> {
|
|
141
|
+
// Aggregate grants in a subquery so pagination is over users, not grants.
|
|
142
|
+
const activeAdminRoles = executor
|
|
143
|
+
.select({
|
|
144
|
+
userId: adminGrants.userId,
|
|
145
|
+
roles: sql<AdminRole[]>`json_agg(${adminGrants.role})`.as('roles'),
|
|
146
|
+
})
|
|
147
|
+
.from(adminGrants)
|
|
148
|
+
.where(isNull(adminGrants.revokedAt))
|
|
149
|
+
.groupBy(adminGrants.userId)
|
|
150
|
+
.as('active_admin_roles');
|
|
151
|
+
|
|
152
|
+
const conditions: SQL[] = [];
|
|
153
|
+
const cursorCondition = timestampIdCursorWhere({
|
|
154
|
+
timestampColumn: users.createdAt,
|
|
155
|
+
idColumn: users.id,
|
|
156
|
+
cursor: params.cursor,
|
|
157
|
+
});
|
|
158
|
+
if (cursorCondition) conditions.push(cursorCondition);
|
|
159
|
+
if (params.status) conditions.push(eq(users.status, params.status));
|
|
160
|
+
|
|
161
|
+
addEligibilityCondition(conditions, params, activeAdminRoles.userId);
|
|
162
|
+
addSearchConditions(conditions, params.search);
|
|
163
|
+
|
|
164
|
+
const rows = await executor
|
|
165
|
+
.select({
|
|
166
|
+
id: users.id,
|
|
167
|
+
email: users.email,
|
|
168
|
+
name: users.name,
|
|
169
|
+
emailVerifiedAt: users.emailVerifiedAt,
|
|
170
|
+
status: users.status,
|
|
171
|
+
createdAt: users.createdAt,
|
|
172
|
+
adminRoles: activeAdminRoles.roles,
|
|
173
|
+
})
|
|
174
|
+
.from(users)
|
|
175
|
+
.leftJoin(activeAdminRoles, eq(activeAdminRoles.userId, users.id))
|
|
176
|
+
.where(and(...conditions))
|
|
177
|
+
.orderBy(desc(users.createdAt), desc(users.id))
|
|
178
|
+
.limit(params.limit + 1);
|
|
179
|
+
|
|
180
|
+
const mappedRows = rows.map(
|
|
181
|
+
({adminRoles, ...row}): AdministratorUserSummary => ({
|
|
182
|
+
...row,
|
|
183
|
+
adminRole: row.status === 'active' ? highestAdminRole(adminRoles ?? []) : null,
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
const page = paginateTimestampIdRows({
|
|
187
|
+
rows: mappedRows,
|
|
188
|
+
limit: params.limit,
|
|
189
|
+
timestampKey: 'createdAt',
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return {rows: page.pageRows, nextCursor: page.nextCursor};
|
|
193
|
+
}
|
package/src/db/admin-users.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import type {TimestampIdCursor} from '@shipfox/node-drizzle';
|
|
1
2
|
import type {AdministratorUserSummary} from '#core/entities/administrator-read-model.js';
|
|
2
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
findAdministratorUserSummary,
|
|
5
|
+
type ListAdministratorUserSummariesResult,
|
|
6
|
+
listAdministratorUserSummaries,
|
|
7
|
+
} from './admin-user-summary.js';
|
|
3
8
|
import {db} from './db.js';
|
|
4
9
|
|
|
5
10
|
export type AdministratorUserRecord = AdministratorUserSummary;
|
|
@@ -11,3 +16,18 @@ export async function findAdministratorUser(
|
|
|
11
16
|
): Promise<AdministratorUserRecord | undefined> {
|
|
12
17
|
return await findAdministratorUserSummary(db(), params);
|
|
13
18
|
}
|
|
19
|
+
|
|
20
|
+
export interface ListAdministratorUsersParams {
|
|
21
|
+
actorId: string;
|
|
22
|
+
limit: number;
|
|
23
|
+
cursor?: TimestampIdCursor | undefined;
|
|
24
|
+
search?: string | undefined;
|
|
25
|
+
status?: AdministratorUserSummary['status'] | undefined;
|
|
26
|
+
eligible?: boolean | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function listAdministratorUsers(
|
|
30
|
+
params: ListAdministratorUsersParams,
|
|
31
|
+
): Promise<ListAdministratorUserSummariesResult> {
|
|
32
|
+
return await listAdministratorUserSummaries(db(), params);
|
|
33
|
+
}
|